PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.10
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.10
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 / store.php

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

721 lines 24.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 'WINDOW_BODY' => array(
364 'type' => 'image',
365 'prop' => '--os-window-body-image',
366 ),
367 'TABBAR' => array(
368 'type' => 'image',
369 'prop' => '--os-tabs-image',
370 ),
371 // --- Shell surfaces. ---
372 'DOCK' => array(
373 'type' => 'image',
374 'prop' => '--os-dock-bg-image',
375 ),
376 'DOCK_ITEM' => array(
377 'type' => 'image',
378 'prop' => '--os-dock-item-image',
379 ),
380 'DESKTOP' => array(
381 'type' => 'image',
382 'prop' => '--os-desktop-image',
383 ),
384 'ICON_TILE' => array(
385 'type' => 'image',
386 'prop' => '--os-tile-image',
387 ),
388 'WIDGET' => array(
389 'type' => 'image',
390 'prop' => '--os-widget-image',
391 ),
392 // --- Component-kit surfaces (window bodies + popovers). ---
393 'MENU' => array(
394 'type' => 'image',
395 'prop' => '--os-ui-menu-bg-image',
396 ),
397 'DIALOG' => array(
398 'type' => 'image',
399 'prop' => '--os-ui-dialog-bg-image',
400 ),
401 'SCRIM' => array(
402 'type' => 'image',
403 'prop' => '--os-ui-scrim-image',
404 ),
405 'PANEL' => array(
406 'type' => 'image',
407 'prop' => '--os-ui-panel-bg-image',
408 ),
409 'TOAST' => array(
410 'type' => 'image',
411 'prop' => '--os-ui-toast-bg-image',
412 ),
413 'TABLE_HEADER' => array(
414 'type' => 'image',
415 'prop' => '--os-ui-table-header-bg-image',
416 ),
417 'BUTTON' => array(
418 'type' => 'image',
419 'prop' => '--os-ui-button-bg-image',
420 ),
421 );
422 /**
423 * Filters the texture slots a desktop theme manifest may address.
424 *
425 * Each entry needs a `type` (`image` or `border-image`) and a
426 * `prop` — the custom-property base name the compiler writes to.
427 * With both present the slot is fully wired: the sanitizer accepts
428 * it and the compiler emits it. All that remains is a CSS rule
429 * that reads the property, which the plugin adding the slot ships
430 * in its own stylesheet.
431 *
432 * An entry with no `prop` is accepted by the sanitizer but emits
433 * nothing — that combination is a bug, not a feature.
434 *
435 * @param array<string,array> $slots Map of slot =>
436 * `{ type, prop, companions?,
437 * sizeGroup? }`.
438 */
439 return (array) apply_filters( 'openstation_desktop_theme_texture_slots', $slots );
440 }
441
442 /**
443 * The OS-settings keys a manifest's `recommendedOsSettings` block may
444 * address, each mapped to the grammar the sanitizer enforces.
445 *
446 * Two grammars, and the difference is not cosmetic:
447 *
448 * - `enum` — a closed list of core values. The whole set is known
449 * to PHP, so an unknown value is provably wrong and is
450 * dropped here.
451 * - `slug` — a `sanitize_key()`-clean id whose validity only the
452 * JS registry knows (`dockRailRenderer` and
453 * `windowReveal` resolve against things registered at
454 * runtime, by core AND by plugins). PHP checks the
455 * charset; the shell drops the key at apply time when
456 * nothing is registered under that id, which is the same
457 * "resolve at use time" contract
458 * `openstation_sanitize_os_settings()` already follows
459 * for the user's own `dockRailRenderer`.
460 * - `int` — a whole number clamped into `{ min, max }`. Clamped
461 * rather than dropped: a theme asking for a reveal
462 * slower than the shell will play is expressing "slow",
463 * and the honest reading of that is the slowest we do
464 * play.
465 *
466 * A key absent from this table is dropped from the manifest. That is
467 * the point: a theme RECOMMENDS presentation, so it may only reach
468 * the handful of layout preferences a user would plausibly want a
469 * theme to arrange for them — never a feature toggle, a capability
470 * gate, or anything that changes what the shell can do.
471 *
472 * @return array<string,array{enum?:string[],slug?:bool,int?:array{min:int,max:int}}>
473 */
474 function openstation_desktop_theme_recommended_os_settings_schema() {
475 $schema = array(
476 'dockSize' => array( 'enum' => OPENSTATION_OS_SETTINGS_DOCK_SIZES ),
477 'desktopLayout' => array( 'enum' => OPENSTATION_OS_SETTINGS_DESKTOP_LAYOUTS ),
478 'dockPlacement' => array( 'enum' => OPENSTATION_OS_SETTINGS_DOCK_PLACEMENTS ),
479 'windowRadius' => array( 'enum' => OPENSTATION_OS_SETTINGS_WINDOW_RADII ),
480 'adminBarMode' => array( 'enum' => OPENSTATION_OS_SETTINGS_ADMIN_BAR_MODES ),
481 'dockRailRenderer' => array( 'slug' => true ),
482 'windowReveal' => array( 'slug' => true ),
483
484 /*
485 * The accent swatch id. A registry lookup rather than an enum
486 * because the list is filterable
487 * (`openstation_accent_colors`), so the shell resolves the id
488 * against whatever swatches the site actually offers and skips
489 * the key when nothing answers to it.
490 *
491 * It earns its place here for the same reason the rest do: a
492 * theme's palette and the accent are one composition, and a
493 * dark station wearing the WordPress blue it was never drawn
494 * against is the most visible way that composition comes
495 * apart. Still a recommendation — applied once, and the user's
496 * pick afterwards is theirs.
497 */
498 'accent' => array( 'slug' => true ),
499 'windowRevealDuration' => array(
500 'int' => array(
501 'min' => OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MIN,
502 'max' => OPENSTATION_OS_SETTINGS_REVEAL_DURATION_MAX,
503 ),
504 ),
505 );
506 /**
507 * Filters the OS-settings keys a desktop theme may recommend.
508 *
509 * A plugin that adds its own presentation preference to OS
510 * Settings can opt it into theme recommendations by adding an
511 * entry here — `array( 'enum' => array( … ) )` for a closed set,
512 * `array( 'slug' => true )` for a registry id resolved at apply
513 * time, `array( 'int' => array( 'min' => …, 'max' => … ) )` for a
514 * clamped whole number.
515 *
516 * Anything added is written into user meta the first time a user
517 * activates a theme that recommends it, so keep the list to
518 * presentation. Feature switches and capability-adjacent settings
519 * do not belong here.
520 *
521 * @param array<string,array> $schema Map of settings key =>
522 * `{ enum }`, `{ slug }`, or `{ int }`.
523 */
524 $schema = (array) apply_filters(
525 'openstation_desktop_theme_recommended_os_settings_schema',
526 $schema
527 );
528
529 $out = array();
530 foreach ( $schema as $key => $rule ) {
531 if ( ! is_string( $key ) || '' === $key || ! is_array( $rule ) ) {
532 continue;
533 }
534 if ( ! empty( $rule['enum'] ) && is_array( $rule['enum'] ) ) {
535 $values = array();
536 foreach ( $rule['enum'] as $value ) {
537 if ( is_string( $value ) && '' !== $value ) {
538 $values[] = $value;
539 }
540 }
541 if ( ! empty( $values ) ) {
542 $out[ $key ] = array( 'enum' => $values );
543 }
544 continue;
545 }
546 if ( ! empty( $rule['slug'] ) ) {
547 $out[ $key ] = array( 'slug' => true );
548 continue;
549 }
550 if (
551 ! empty( $rule['int'] )
552 && is_array( $rule['int'] )
553 && isset( $rule['int']['min'], $rule['int']['max'] )
554 && is_numeric( $rule['int']['min'] )
555 && is_numeric( $rule['int']['max'] )
556 && (int) $rule['int']['min'] <= (int) $rule['int']['max']
557 ) {
558 $out[ $key ] = array(
559 'int' => array(
560 'min' => (int) $rule['int']['min'],
561 'max' => (int) $rule['int']['max'],
562 ),
563 );
564 }
565 }
566 return $out;
567 }
568
569 /**
570 * File extensions a theme asset may carry, per asset kind.
571 *
572 * Two kinds exist, and they are deliberately disjoint:
573 *
574 * - `image` — icons, textures, the preview. Everything the
575 * compiler turns into a `url()` inside a `background-image` or
576 * an `<img src>`.
577 * - `font` — files referenced from a generated `@font-face`.
578 * Binary containers parsed by the browser's font engine; unlike
579 * SVG they carry no script surface, which is why they can be
580 * accepted without a sanitizer pass of their own.
581 *
582 * A kind the caller doesn't recognise gets an EMPTY list, so a typo
583 * fails closed.
584 *
585 * @param string $kind `'image'` or `'font'`.
586 * @return string[] Lowercase extensions, no leading dot.
587 */
588 function openstation_desktop_theme_asset_extensions( $kind = 'image' ) {
589 $kind = strtolower( trim( (string) $kind ) );
590 $map = array(
591 'image' => array( 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg' ),
592 'font' => array( 'woff2', 'woff', 'ttf', 'otf' ),
593 );
594 /**
595 * Filters the extensions accepted for one kind of theme asset.
596 *
597 * Adding anything the browser parses as script (`css`, `js`,
598 * `html`, `xml`, `svgz`) or anything the server executes defeats
599 * the security model this whole feature rests on.
600 *
601 * @param string[] $extensions Lowercase extensions, no dot.
602 * @param string $kind `'image'` or `'font'`.
603 */
604 $extensions = (array) apply_filters(
605 'openstation_desktop_theme_asset_extensions',
606 isset( $map[ $kind ] ) ? $map[ $kind ] : array(),
607 $kind
608 );
609
610 return array_values(
611 array_filter(
612 array_map(
613 static function ( $ext ) {
614 return strtolower( trim( (string) $ext, ". \t\n\r\0\x0B" ) );
615 },
616 $extensions
617 ),
618 'strlen'
619 )
620 );
621 }
622
623 /**
624 * Maximum number of `@font-face` rules one theme may declare, and
625 * the maximum number of source files per face.
626 *
627 * @return array{max_faces:int,max_sources:int}
628 */
629 function openstation_desktop_theme_font_caps() {
630 /**
631 * Filters the desktop-theme font caps.
632 *
633 * @param array $caps `{ max_faces, max_sources }`.
634 */
635 $caps = (array) apply_filters(
636 'openstation_desktop_theme_font_caps',
637 array(
638 // A UI font at a few weights, a mono, a display face.
639 'max_faces' => 16,
640 // woff2 + woff is the realistic ceiling in 2025; four
641 // leaves room for a ttf/otf tail on ancient targets.
642 'max_sources' => 4,
643 )
644 );
645 return array(
646 'max_faces' => max( 1, (int) ( $caps['max_faces'] ?? 16 ) ),
647 'max_sources' => max( 1, (int) ( $caps['max_sources'] ?? 4 ) ),
648 );
649 }
650
651 /**
652 * Hard caps applied while walking an uploaded ZIP.
653 *
654 * @return array{max_entries:int,max_uncompressed:int,max_file:int,extensions:string[]}
655 */
656 function openstation_desktop_theme_zip_caps() {
657 $caps = array(
658 // Entry count — a theme is a manifest plus a couple of dozen
659 // images; anything past this is a zip bomb or a mistake.
660 'max_entries' => 256,
661 // Total uncompressed bytes across every entry (32 MB).
662 'max_uncompressed' => 32 * 1024 * 1024,
663 // Single-entry uncompressed cap (8 MB).
664 'max_file' => 8 * 1024 * 1024,
665 // Everything else is refused outright. No CSS, no JS, ever.
666 //
667 // `txt` / `md` are here so an archive may carry the licence
668 // notice its bundled fonts require. They are NOT referenceable
669 // from any manifest field — every resolver demands an image or
670 // font extension — so they are validated, never extracted into
671 // the live directory, and discarded with the staging dir.
672 'extensions' => array_merge(
673 array( 'json', 'txt', 'md' ),
674 openstation_desktop_theme_asset_extensions( 'image' ),
675 openstation_desktop_theme_asset_extensions( 'font' )
676 ),
677 );
678 /**
679 * Filters the caps enforced while validating an uploaded desktop
680 * theme ZIP.
681 *
682 * Widening `extensions` to anything executable or anything the
683 * browser parses as script (`css`, `js`, `html`, `xml`) defeats
684 * the whole security model of this feature.
685 *
686 * @param array $caps See the return shape above.
687 */
688 $caps = (array) apply_filters( 'openstation_desktop_theme_zip_caps', $caps );
689
690 return array(
691 'max_entries' => max( 1, (int) ( $caps['max_entries'] ?? 256 ) ),
692 'max_uncompressed' => max( 1, (int) ( $caps['max_uncompressed'] ?? 33554432 ) ),
693 'max_file' => max( 1, (int) ( $caps['max_file'] ?? 8388608 ) ),
694 'extensions' => array_values(
695 array_filter(
696 array_map(
697 static function ( $ext ) {
698 return strtolower( trim( (string) $ext, ". \t\n\r\0\x0B" ) );
699 },
700 (array) ( $caps['extensions'] ?? array() )
701 ),
702 'strlen'
703 )
704 ),
705 );
706 }
707
708 /**
709 * Maximum number of themes the payload ships to the shell.
710 *
711 * @return int
712 */
713 function openstation_desktop_themes_payload_cap() {
714 /**
715 * Filters how many desktop themes are announced to the shell.
716 *
717 * @param int $cap Default 24.
718 */
719 return max( 1, (int) apply_filters( 'openstation_desktop_themes_payload_cap', 24 ) );
720 }
721