PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
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 0.9.8, at includes/desktop-themes/store.php

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