PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 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 All 37 releases
desktop-mode / includes / desktop-themes / store.php

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

792 lines 27.2 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 storage + accessors.
4 *
5 * Owns the uploads directory, the site option that indexes installed
6 * themes, and every filterable knob the rest of the module reads
7 * (upload capability, slot allowlists, ZIP caps).
8 *
9 * Storage layout:
10 *
11 * uploads/desktop-mode-themes/
12 * index.php <- silence
13 * .htaccess <- exec-off, NOT deny-all
14 * <slug>/
15 * theme.json <- the author's raw manifest
16 * theme.css <- compiled by us: custom props +
17 *
18 * @font-face rules we generated
19 * icons/… textures/… fonts/… preview.png
20 *
21 * The `.htaccess` here is deliberately NOT the deny-all one the
22 * stored-files module drops: theme assets are `<img src>` / CSS
23 * `url()` targets and MUST be servable. It turns the PHP engine off
24 * and denies executable extensions instead. Belt and braces: the
25 * installer only ever moves manifest-referenced files whose
26 * extension is on the image or font allowlist, so nothing executable
27 * lands in the first place.
28 *
29 * @package OpenStation
30 */
31
32 defined( 'ABSPATH' ) || exit;
33
34 /**
35 * Site option holding the installed-theme index. Autoload: no.
36 *
37 * The VALUE keeps its pre-rebrand spelling on purpose: rows are already
38 * stored under it on live installs, so renaming it would orphan every
39 * one. The mismatch between this constant's name and its value is
40 * deliberate — it is NOT a half-finished rename.
41 *
42 * Not to be confused with the `openstation_desktop_themes` filter, which
43 * once shared this string and is now deliberately decoupled.
44 */
45 const OPENSTATION_DESKTOP_THEMES_OPTION = 'desktop_mode_desktop_themes';
46
47 /**
48 * Absolute path of the desktop-themes base dir (no trailing slash),
49 * or of one theme's dir when `$slug` is given. Pure path math —
50 * nothing is created; see {@see openstation_desktop_themes_ensure_dir()}.
51 *
52 * The `desktop-mode-themes` segment is the pre-rebrand spelling and is
53 * frozen: admin-uploaded theme ZIPs already unpack there. Renaming it
54 * points the plugin at an empty directory and every installed theme
55 * vanishes from the picker. The mismatch with the function name is
56 * deliberate.
57 *
58 * @param string $slug Optional. Theme slug.
59 * @return string
60 */
61 function openstation_desktop_themes_dir( $slug = '' ) {
62 $uploads = wp_get_upload_dir();
63 $base = trailingslashit( $uploads['basedir'] ) . 'desktop-mode-themes';
64 /**
65 * Filters the desktop-theme storage base directory.
66 *
67 * Whatever this points at must be web-servable — the compiled
68 * `theme.css` and every image are loaded by the browser.
69 *
70 * @param string $base Absolute path, no trailing slash.
71 */
72 $base = (string) apply_filters( 'openstation_desktop_themes_base_dir', $base );
73 $slug = sanitize_key( (string) $slug );
74 return '' !== $slug ? $base . '/' . $slug : $base;
75 }
76
77 /**
78 * Public URL of the desktop-themes base dir (no trailing slash), or
79 * of one theme's dir when `$slug` is given.
80 *
81 * @param string $slug Optional. Theme slug.
82 * @return string
83 */
84 function openstation_desktop_themes_url( $slug = '' ) {
85 $uploads = wp_get_upload_dir();
86 $url = untrailingslashit( $uploads['baseurl'] ) . '/desktop-mode-themes';
87 /**
88 * Filters the desktop-theme storage base URL. Must resolve to the
89 * same bytes `openstation_desktop_themes_base_dir` points at.
90 *
91 * @param string $url Absolute URL, no trailing slash.
92 */
93 $url = (string) apply_filters( 'openstation_desktop_themes_base_url', $url );
94 $slug = sanitize_key( (string) $slug );
95 return '' !== $slug ? $url . '/' . $slug : $url;
96 }
97
98 /**
99 * Create (idempotently) the base dir and drop the protection files.
100 *
101 * @return string|WP_Error Base dir path, or `WP_Error` when the
102 * filesystem refuses.
103 */
104 function openstation_desktop_themes_ensure_dir() {
105 $base = openstation_desktop_themes_dir();
106 if ( ! wp_mkdir_p( $base ) ) {
107 return new WP_Error(
108 'openstation_desktop_theme_mkdir_failed',
109 __( 'Could not create the desktop-themes directory.', 'desktop-mode' ),
110 array( 'status' => 500 )
111 );
112 }
113
114 $index = $base . '/index.php';
115 if ( ! file_exists( $index ) ) {
116 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
117 file_put_contents( $index, "<?php // Silence is golden.\n" );
118 }
119
120 // Exec-off, NOT deny-all — theme assets must stay servable. The
121 // `mod_php` variants cover both the module names Apache has used;
122 // the `FilesMatch` block is the fallback for FPM/CGI setups where
123 // `php_flag` isn't available.
124 $htaccess = $base . '/.htaccess';
125 if ( ! file_exists( $htaccess ) ) {
126 $rules = "Options -Indexes\n"
127 . "<IfModule mod_php.c>\n\tphp_flag engine off\n</IfModule>\n"
128 . "<IfModule mod_php7.c>\n\tphp_flag engine off\n</IfModule>\n"
129 . "<FilesMatch \"\\.(?i:php|phtml|phar|php3|php4|php5|php7|php8|pht|phps|cgi|pl|asp|aspx|jsp|shtml|htaccess)$\">\n"
130 . "\t<IfModule mod_authz_core.c>\n\t\tRequire all denied\n\t</IfModule>\n"
131 . "\t<IfModule !mod_authz_core.c>\n\t\tOrder deny,allow\n\t\tDeny from all\n\t</IfModule>\n"
132 . "</FilesMatch>\n";
133 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
134 file_put_contents( $htaccess, $rules );
135 }
136
137 return $base;
138 }
139
140 /**
141 * Read the installed-theme index (map of slug => stored entry).
142 *
143 * @return array<string,array>
144 */
145 function openstation_desktop_themes_index() {
146 $raw = get_option( OPENSTATION_DESKTOP_THEMES_OPTION, array() );
147 if ( ! is_array( $raw ) ) {
148 return array();
149 }
150 $out = array();
151 foreach ( $raw as $slug => $entry ) {
152 if ( ! is_string( $slug ) || '' === $slug || ! is_array( $entry ) ) {
153 continue;
154 }
155 $out[ $slug ] = $entry;
156 }
157 return $out;
158 }
159
160 /**
161 * Persist the installed-theme index.
162 *
163 * Uses `add_option( …, '', 'no' )` on first write so the option is
164 * never autoloaded — the index carries whole manifests and has no
165 * business on every single page load.
166 *
167 * @param array<string,array> $index Map of slug => stored entry.
168 * @return void
169 */
170 function openstation_desktop_themes_put_index( $index ) {
171 $index = is_array( $index ) ? $index : array();
172 if ( false === get_option( OPENSTATION_DESKTOP_THEMES_OPTION, false ) ) {
173 add_option( OPENSTATION_DESKTOP_THEMES_OPTION, $index, '', 'no' );
174 return;
175 }
176 update_option( OPENSTATION_DESKTOP_THEMES_OPTION, $index, false );
177 }
178
179 /**
180 * Fetch one installed theme's stored entry.
181 *
182 * @param string $slug Theme slug.
183 * @return array|null Stored entry, or `null` when not installed.
184 */
185 function openstation_desktop_theme_get( $slug ) {
186 $slug = sanitize_key( (string) $slug );
187 $index = openstation_desktop_themes_index();
188 return isset( $index[ $slug ] ) ? $index[ $slug ] : null;
189 }
190
191 /**
192 * Capability required to upload / delete desktop themes.
193 *
194 * @return string
195 */
196 function openstation_desktop_theme_upload_capability() {
197 /**
198 * Filters the capability required to manage the site's desktop
199 * theme library. Picking a theme is per-user and never gated.
200 *
201 * @param string $capability Default `manage_options`.
202 */
203 return (string) apply_filters( 'openstation_desktop_theme_upload_capability', 'manage_options' );
204 }
205
206 /**
207 * Derive the storage slug from a manifest `id`.
208 *
209 * Manifest ids may be namespaced (`vendor/neon-glass`); the slug
210 * flattens the slash so it is a legal single directory name.
211 *
212 * @param string $id Manifest id.
213 * @return string Slug, or `''` when the id yields nothing usable.
214 */
215 function openstation_desktop_theme_slug_from_id( $id ) {
216 return sanitize_key( str_replace( '/', '-', (string) $id ) );
217 }
218
219 /**
220 * The icon slots a manifest may address.
221 *
222 * Single source of truth for the PHP side; must stay equal to the
223 * `DESKTOP_THEME_SLOTS` constants in `src/desktop-themes/slots.ts`.
224 * `APP:<slug>` entries are matched by pattern, not by this list.
225 *
226 * @return string[]
227 */
228 function openstation_desktop_theme_icon_slots() {
229 $slots = array(
230 // Window controls — one per `<os-window-button>` key.
231 'WINDOW_CONTROL_MINIMIZE',
232 'WINDOW_CONTROL_MAXIMIZE',
233 'WINDOW_CONTROL_FULLSCREEN',
234 'WINDOW_CONTROL_FULLSCREEN_EXIT',
235 'WINDOW_CONTROL_CLOSE',
236 'WINDOW_CONTROL_MENU',
237 'WINDOW_CONTROL_RELOAD',
238 'WINDOW_CONTROL_DETACH',
239 // System tiles.
240 'OS_SETTINGS',
241 'RECYCLE_BIN',
242 'BUG_REPORT',
243 'EXIT_OPENSTATION',
244 'PWA_INSTALL',
245 // Apps.
246 'DEFAULT_APP_ICON',
247 // Desktop files.
248 'FOLDER',
249 'FILE_SHORTCUT',
250 'FILE_POST',
251 'FILE_ATTACHMENT',
252 'FILE_UPLOAD',
253 'FILE_USER',
254 'FILE_TERM',
255 'FILE_COMMENT',
256 'FILE_BOOKMARK',
257 'FILE_LINK',
258 'FILE_EMBED',
259 // Recycle-bin row actions.
260 'RECYCLE_RESTORE',
261 'RECYCLE_DELETE',
262 );
263 /**
264 * Filters the icon slots a desktop theme manifest may address.
265 *
266 * Entries not on this list (and not matching the `APP:<slug>`
267 * pattern) are dropped from the manifest during sanitization.
268 *
269 * @param string[] $slots Slot names.
270 */
271 return (array) apply_filters( 'openstation_desktop_theme_icon_slots', $slots );
272 }
273
274 /**
275 * The texture slots a manifest may address, each mapped to the
276 * grammar the sanitizer enforces AND the custom property the
277 * compiler writes it to.
278 *
279 * Four keys make up a slot definition:
280 *
281 * - `type` — the structural discriminator. `image` slots
282 * become `background-image` custom properties;
283 * `border-image` slots become the four
284 * `border-image-*` properties.
285 * - `prop` — the custom-property BASE name. An `image` slot
286 * emits `<prop>`, `<prop>-repeat`, `<prop>-size`;
287 * a `border-image` slot emits `<prop>-source`,
288 * `-slice`, `-width`, `-repeat`.
289 * - `companions`— set to `false` when a slot is a variant of
290 * another one and should inherit its `repeat` /
291 * `size` rather than declare its own
292 * (`TITLEBAR_FOCUSED`).
293 * - `sizeGroup` — custom property shared by a family of slots that
294 * must render at one size (the four window
295 * corners). First declared wins.
296 *
297 * **The compiler reads this table and nothing else.** That is what
298 * makes `openstation_desktop_theme_texture_slots` a complete
299 * extension point: a plugin that adds an entry here, and writes one
300 * CSS rule consuming `var( <prop>, none )`, has textured a surface
301 * the framework never knew about — no core change, no compiler
302 * change. See docs/desktop-themes.md § "Texturing your own surface".
303 *
304 * @return array<string,array{type:string,prop:string}>
305 */
306 function openstation_desktop_theme_texture_slots() {
307 $corner_size = '--os-window-corner-size';
308 $slots = array(
309 // --- Window chrome. ---
310 'TITLEBAR' => array(
311 'type' => 'image',
312 'prop' => '--os-titlebar-image',
313 ),
314 'TITLEBAR_FOCUSED' => array(
315 'type' => 'image',
316 'prop' => '--os-titlebar-image-focused',
317 // Shares the base slot's repeat + size; only the image
318 // differs, so a theme shipping one strip gets both states.
319 'companions' => false,
320 ),
321 'WINDOW_FRAME' => array(
322 'type' => 'border-image',
323 'prop' => '--os-window-border-image',
324 ),
325 'WINDOW_FRAME_FOCUSED' => array(
326 'type' => 'border-image',
327 'prop' => '--os-window-border-image-focused',
328 ),
329 'WINDOW_CORNER_NE' => array(
330 'type' => 'image',
331 'prop' => '--os-window-corner-ne-image',
332 'sizeGroup' => $corner_size,
333 ),
334 'WINDOW_CORNER_NW' => array(
335 'type' => 'image',
336 'prop' => '--os-window-corner-nw-image',
337 'sizeGroup' => $corner_size,
338 ),
339 'WINDOW_CORNER_SE' => array(
340 'type' => 'image',
341 'prop' => '--os-window-corner-se-image',
342 'sizeGroup' => $corner_size,
343 ),
344 'WINDOW_CORNER_SW' => array(
345 'type' => 'image',
346 'prop' => '--os-window-corner-sw-image',
347 'sizeGroup' => $corner_size,
348 ),
349 // The control cluster and the individual control faces. Both
350 // are TRANSPARENT by default, which is what lets a TITLEBAR
351 // texture run edge to edge underneath them. A theme that wants
352 // the controls to sit on a plate paints one here (and usually
353 // sets `--os-titlebar-controls-radius` +
354 // `-padding` to give it a shape).
355 'TITLEBAR_CONTROLS' => array(
356 'type' => 'image',
357 'prop' => '--os-titlebar-controls-image',
358 ),
359 'TITLEBAR_BUTTON' => array(
360 'type' => 'image',
361 'prop' => '--os-ui-btn-bg-image',
362 ),
363 // The same face held down (or toggled on). Shares the resting
364 // face's repeat + size, like TITLEBAR_FOCUSED.
365 'TITLEBAR_BUTTON_PRESSED' => array(
366 'type' => 'image',
367 'prop' => '--os-ui-btn-bg-image-pressed',
368 'companions' => false,
369 ),
370 'WINDOW_BODY' => array(
371 'type' => 'image',
372 'prop' => '--os-window-body-image',
373 ),
374 'TABBAR' => array(
375 'type' => 'image',
376 'prop' => '--os-tabs-image',
377 ),
378 // --- Shell surfaces. ---
379 'DOCK' => array(
380 'type' => 'image',
381 'prop' => '--os-dock-bg-image',
382 ),
383 'DOCK_ITEM' => array(
384 'type' => 'image',
385 'prop' => '--os-dock-item-image',
386 ),
387 // The face of the tile whose window is in front, and of a tile
388 // held down. Shares DOCK_ITEM's repeat + size.
389 'DOCK_ITEM_FOCUSED' => array(
390 'type' => 'image',
391 'prop' => '--os-dock-item-image-focused',
392 'companions' => false,
393 ),
394 'DESKTOP' => array(
395 'type' => 'image',
396 'prop' => '--os-desktop-image',
397 ),
398 'ICON_TILE' => array(
399 'type' => 'image',
400 'prop' => '--os-tile-image',
401 ),
402 'WIDGET' => array(
403 'type' => 'image',
404 'prop' => '--os-widget-image',
405 ),
406 // --- Component-kit surfaces (window bodies + popovers). ---
407 'MENU' => array(
408 'type' => 'image',
409 'prop' => '--os-ui-menu-bg-image',
410 ),
411 'DIALOG' => array(
412 'type' => 'image',
413 'prop' => '--os-ui-dialog-bg-image',
414 ),
415 'SCRIM' => array(
416 'type' => 'image',
417 'prop' => '--os-ui-scrim-image',
418 ),
419 'PANEL' => array(
420 'type' => 'image',
421 'prop' => '--os-ui-panel-bg-image',
422 ),
423 'TOAST' => array(
424 'type' => 'image',
425 'prop' => '--os-ui-toast-bg-image',
426 ),
427 'TABLE_HEADER' => array(
428 'type' => 'image',
429 'prop' => '--os-ui-table-header-bg-image',
430 ),
431 'BUTTON' => array(
432 'type' => 'image',
433 'prop' => '--os-ui-button-bg-image',
434 ),
435 );
436 /**
437 * Filters the texture slots a desktop theme manifest may address.
438 *
439 * Each entry needs a `type` (`image` or `border-image`) and a
440 * `prop` — the custom-property base name the compiler writes to.
441 * With both present the slot is fully wired: the sanitizer accepts
442 * it and the compiler emits it. All that remains is a CSS rule
443 * that reads the property, which the plugin adding the slot ships
444 * in its own stylesheet.
445 *
446 * An entry with no `prop` is accepted by the sanitizer but emits
447 * nothing — that combination is a bug, not a feature.
448 *
449 * @param array<string,array> $slots Map of slot =>
450 * `{ type, prop, companions?,
451 * sizeGroup? }`.
452 */
453 return (array) apply_filters( 'openstation_desktop_theme_texture_slots', $slots );
454 }
455
456 /**
457 * The OS-settings keys a manifest's `recommendedOsSettings` block may
458 * address, each mapped to the grammar the sanitizer enforces.
459 *
460 * Two grammars, and the difference is not cosmetic:
461 *
462 * - `enum` — a closed list of core values. The whole set is known
463 * to PHP, so an unknown value is provably wrong and is
464 * dropped here.
465 * - `slug` — a `sanitize_key()`-clean id whose validity only the
466 * JS registry knows (`dockRailRenderer` and
467 * `windowReveal` resolve against things registered at
468 * runtime, by core AND by plugins). PHP checks the
469 * charset; the shell drops the key at apply time when
470 * nothing is registered under that id, which is the same
471 * "resolve at use time" contract
472 * `openstation_sanitize_os_settings()` already follows
473 * for the user's own `dockRailRenderer`.
474 * - `int` — a whole number clamped into `{ min, max }`. Clamped
475 * rather than dropped: a theme asking for a reveal
476 * slower than the shell will play is expressing "slow",
477 * and the honest reading of that is the slowest we do
478 * play.
479 * - `map` — an object of `sanitize_key()`-clean ids to one of a
480 * closed list of values. The shell merges it into the
481 * user's own map, so only the entries a theme names move.
482 * - `ids` — a list of registry ids, namespaced ones included. An
483 * empty list is a value: "none of them".
484 *
485 * A key absent from this table is dropped from the manifest. That is
486 * the point: a theme RECOMMENDS presentation, so it may only reach
487 * the handful of layout preferences a user would plausibly want a
488 * theme to arrange for them — never a feature toggle, a capability
489 * gate, or anything that changes what the shell can do.
490 *
491 * @return array<string,array{enum?:string[],slug?:bool,hex?:bool,int?:array{min:int,max:int},map?:string[],ids?:bool}>
492 */
493 function openstation_desktop_theme_recommended_os_settings_schema() {
494 $schema = array(
495 'dockSize' => array( 'enum' => OPENSTATION_OS_SETTINGS_DOCK_SIZES ),
496 'desktopLayout' => array( 'enum' => OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS ),
497 'dockPlacement' => array( 'enum' => OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS ),
498 'windowRadius' => array( 'enum' => OPENSTATION_OS_SETTINGS_WINDOW_RADII ),
499 'adminBarMode' => array( 'enum' => OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES ),
500 'dockRailRenderer' => array( 'slug' => true ),
501 'windowReveal' => array( 'slug' => true ),
502
503 /*
504 * The accent swatch id. A registry lookup rather than an enum
505 * because the list is filterable
506 * (`openstation_accent_colors`), so the shell resolves the id
507 * against whatever swatches the site actually offers and skips
508 * the key when nothing answers to it.
509 *
510 * It earns its place here for the same reason the rest do: a
511 * theme's palette and the accent are one composition, and a
512 * dark station wearing the WordPress blue it was never drawn
513 * against is the most visible way that composition comes
514 * apart. Still a recommendation — applied once, and the user's
515 * pick afterwards is theirs.
516 */
517 'accent' => array( 'slug' => true ),
518
519 /*
520 * The accent as a colour rather than a swatch id, for a palette
521 * drawn against a hue no swatch offers. The shell uses the
522 * swatch with that value when there is one and the custom
523 * accent otherwise. `accent` wins when both are given and the
524 * site offers that swatch.
525 */
526 'accentColor' => array( 'hex' => true ),
527
528 /*
529 * One of the theme's own `wallpapers`, by its id. The shell
530 * resolves it against the theme first and the wallpaper
531 * registry second, and skips the key when neither answers. A
532 * theme whose look is the desk itself (a flat era colour, a
533 * dither) recommends it so the first activation does not leave
534 * that look under an animated wallpaper it cannot paint over.
535 */
536 'wallpaper' => array( 'slug' => true ),
537
538 /*
539 * Where dock items sit, by item id. Merged into the user's own
540 * map, so a theme moves only what it names, such as hiding a
541 * shell tile its look has no place for.
542 */
543 'navPlacement' => array( 'map' => array( 'both', 'rail', 'desktop', 'hidden' ) ),
544
545 /*
546 * The widget column, by widget id. Not user meta: the column is
547 * stored per browser, so the shell writes it there, and a
548 * browser's first visit starts from the active theme's list
549 * instead of the clock.
550 */
551 'widgets' => array( 'ids' => true ),
552 'windowRevealDuration' => array(
553 'int' => array(
554 'min' => OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN,
555 'max' => OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX,
556 ),
557 ),
558 );
559 /**
560 * Filters the OS-settings keys a desktop theme may recommend.
561 *
562 * A plugin that adds its own presentation preference to OS
563 * Settings can opt it into theme recommendations by adding an
564 * entry here — `array( 'enum' => array( … ) )` for a closed set,
565 * `array( 'slug' => true )` for a registry id resolved at apply
566 * time, `array( 'hex' => true )` for a six-digit hex colour,
567 * `array( 'int' => array( 'min' => …, 'max' => … ) )` for a
568 * clamped whole number, `array( 'map' => array( … ) )` for ids
569 * mapped to a closed set, `array( 'ids' => true )` for a list of ids.
570 *
571 * Anything added is written into user meta the first time a user
572 * activates a theme that recommends it, so keep the list to
573 * presentation. Feature switches and capability-adjacent settings
574 * do not belong here.
575 *
576 * @param array<string,array> $schema Map of settings key =>
577 * `{ enum }`, `{ slug }`, `{ hex }`, `{ int }`,
578 * `{ map }`, or `{ ids }`.
579 */
580 $schema = (array) apply_filters(
581 'openstation_desktop_theme_recommended_os_settings_schema',
582 $schema
583 );
584
585 $out = array();
586 foreach ( $schema as $key => $rule ) {
587 if ( ! is_string( $key ) || '' === $key || ! is_array( $rule ) ) {
588 continue;
589 }
590 if ( ! empty( $rule['enum'] ) && is_array( $rule['enum'] ) ) {
591 $values = array();
592 foreach ( $rule['enum'] as $value ) {
593 if ( is_string( $value ) && '' !== $value ) {
594 $values[] = $value;
595 }
596 }
597 if ( ! empty( $values ) ) {
598 $out[ $key ] = array( 'enum' => $values );
599 }
600 continue;
601 }
602 if ( ! empty( $rule['slug'] ) ) {
603 $out[ $key ] = array( 'slug' => true );
604 continue;
605 }
606 if ( ! empty( $rule['hex'] ) ) {
607 $out[ $key ] = array( 'hex' => true );
608 continue;
609 }
610 if ( ! empty( $rule['ids'] ) ) {
611 $out[ $key ] = array( 'ids' => true );
612 continue;
613 }
614 if ( ! empty( $rule['map'] ) && is_array( $rule['map'] ) ) {
615 $values = array_values( array_filter( $rule['map'], 'is_string' ) );
616 if ( ! empty( $values ) ) {
617 $out[ $key ] = array( 'map' => $values );
618 }
619 continue;
620 }
621 if (
622 ! empty( $rule['int'] )
623 && is_array( $rule['int'] )
624 && isset( $rule['int']['min'], $rule['int']['max'] )
625 && is_numeric( $rule['int']['min'] )
626 && is_numeric( $rule['int']['max'] )
627 && (int) $rule['int']['min'] <= (int) $rule['int']['max']
628 ) {
629 $out[ $key ] = array(
630 'int' => array(
631 'min' => (int) $rule['int']['min'],
632 'max' => (int) $rule['int']['max'],
633 ),
634 );
635 }
636 }
637 return $out;
638 }
639
640 /**
641 * File extensions a theme asset may carry, per asset kind.
642 *
643 * Two kinds exist, and they are deliberately disjoint:
644 *
645 * - `image` — icons, textures, the preview. Everything the
646 * compiler turns into a `url()` inside a `background-image` or
647 * an `<img src>`.
648 * - `font` — files referenced from a generated `@font-face`.
649 * Binary containers parsed by the browser's font engine; unlike
650 * SVG they carry no script surface, which is why they can be
651 * accepted without a sanitizer pass of their own.
652 *
653 * A kind the caller doesn't recognise gets an EMPTY list, so a typo
654 * fails closed.
655 *
656 * @param string $kind `'image'` or `'font'`.
657 * @return string[] Lowercase extensions, no leading dot.
658 */
659 function openstation_desktop_theme_asset_extensions( $kind = 'image' ) {
660 $kind = strtolower( trim( (string) $kind ) );
661 $map = array(
662 'image' => array( 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg' ),
663 'font' => array( 'woff2', 'woff', 'ttf', 'otf' ),
664 );
665 /**
666 * Filters the extensions accepted for one kind of theme asset.
667 *
668 * Adding anything the browser parses as script (`css`, `js`,
669 * `html`, `xml`, `svgz`) or anything the server executes defeats
670 * the security model this whole feature rests on.
671 *
672 * @param string[] $extensions Lowercase extensions, no dot.
673 * @param string $kind `'image'` or `'font'`.
674 */
675 $extensions = (array) apply_filters(
676 'openstation_desktop_theme_asset_extensions',
677 isset( $map[ $kind ] ) ? $map[ $kind ] : array(),
678 $kind
679 );
680
681 return array_values(
682 array_filter(
683 array_map(
684 static function ( $ext ) {
685 return strtolower( trim( (string) $ext, ". \t\n\r\0\x0B" ) );
686 },
687 $extensions
688 ),
689 'strlen'
690 )
691 );
692 }
693
694 /**
695 * Maximum number of `@font-face` rules one theme may declare, and
696 * the maximum number of source files per face.
697 *
698 * @return array{max_faces:int,max_sources:int}
699 */
700 function openstation_desktop_theme_font_caps() {
701 /**
702 * Filters the desktop-theme font caps.
703 *
704 * @param array $caps `{ max_faces, max_sources }`.
705 */
706 $caps = (array) apply_filters(
707 'openstation_desktop_theme_font_caps',
708 array(
709 // A UI font at a few weights, a mono, a display face.
710 'max_faces' => 16,
711 // woff2 + woff is the realistic ceiling in 2025; four
712 // leaves room for a ttf/otf tail on ancient targets.
713 'max_sources' => 4,
714 )
715 );
716 return array(
717 'max_faces' => max( 1, (int) ( $caps['max_faces'] ?? 16 ) ),
718 'max_sources' => max( 1, (int) ( $caps['max_sources'] ?? 4 ) ),
719 );
720 }
721
722 /**
723 * Hard caps applied while walking an uploaded ZIP.
724 *
725 * @return array{max_entries:int,max_uncompressed:int,max_file:int,extensions:string[]}
726 */
727 function openstation_desktop_theme_zip_caps() {
728 $caps = array(
729 // Entry count — a theme is a manifest plus a couple of dozen
730 // images; anything past this is a zip bomb or a mistake.
731 'max_entries' => 256,
732 // Total uncompressed bytes across every entry (32 MB).
733 'max_uncompressed' => 32 * 1024 * 1024,
734 // Single-entry uncompressed cap (8 MB).
735 'max_file' => 8 * 1024 * 1024,
736 // Everything else is refused outright. No CSS, no JS, ever.
737 //
738 // `txt` / `md` are here so an archive may carry the licence
739 // notice its bundled fonts require. They are NOT referenceable
740 // from any manifest field — every resolver demands an image or
741 // font extension — so they are validated, never extracted into
742 // the live directory, and discarded with the staging dir.
743 'extensions' => array_merge(
744 array( 'json', 'txt', 'md' ),
745 openstation_desktop_theme_asset_extensions( 'image' ),
746 openstation_desktop_theme_asset_extensions( 'font' )
747 ),
748 );
749 /**
750 * Filters the caps enforced while validating an uploaded desktop
751 * theme ZIP.
752 *
753 * Widening `extensions` to anything executable or anything the
754 * browser parses as script (`css`, `js`, `html`, `xml`) defeats
755 * the whole security model of this feature.
756 *
757 * @param array $caps See the return shape above.
758 */
759 $caps = (array) apply_filters( 'openstation_desktop_theme_zip_caps', $caps );
760
761 return array(
762 'max_entries' => max( 1, (int) ( $caps['max_entries'] ?? 256 ) ),
763 'max_uncompressed' => max( 1, (int) ( $caps['max_uncompressed'] ?? 33554432 ) ),
764 'max_file' => max( 1, (int) ( $caps['max_file'] ?? 8388608 ) ),
765 'extensions' => array_values(
766 array_filter(
767 array_map(
768 static function ( $ext ) {
769 return strtolower( trim( (string) $ext, ". \t\n\r\0\x0B" ) );
770 },
771 (array) ( $caps['extensions'] ?? array() )
772 ),
773 'strlen'
774 )
775 ),
776 );
777 }
778
779 /**
780 * Maximum number of themes the payload ships to the shell.
781 *
782 * @return int
783 */
784 function openstation_desktop_themes_payload_cap() {
785 /**
786 * Filters how many desktop themes are announced to the shell.
787 *
788 * @param int $cap Default 24.
789 */
790 return max( 1, (int) apply_filters( 'openstation_desktop_themes_payload_cap', 24 ) );
791 }
792