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

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

703 lines 22.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * OpenStation — Desktop-theme ZIP installer.
4 *
5 * The pipeline, in order, with the staging directory cleaned up on
6 * every exit path:
7 *
8 * 1. Walk the archive with `ZipArchive::statIndex()` and reject it
9 * wholesale on traversal, absolute paths, NUL bytes, forbidden
10 * extensions, or any cap breach. Nothing is written to disk
11 * until the whole archive has passed.
12 * 2. Extract into `.staging-<uuid>/` inside the themes base dir.
13 * 3. Sanitize the manifest (which resolves every asset reference
14 * against the staging dir — an entry pointing outside it, or at
15 * a file that isn't there, is dropped).
16 * 4. Sanitize every referenced SVG in place.
17 * 5. Delete + recreate the final theme dir. **Re-uploading a theme
18 * with the same id is an update**, not an error.
19 * 6. Move ONLY the manifest-referenced assets across. Anything the
20 * manifest never mentions never reaches the live directory.
21 * 7. Compile `theme.css`, write it, update the option index.
22 *
23 * @package OpenStation
24 */
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Recursively delete a directory tree.
30 *
31 * Guarded: refuses to act on anything that isn't inside the
32 * desktop-themes base dir, so a bad caller can't turn this into an
33 * arbitrary-delete primitive.
34 *
35 * @internal
36 *
37 * @param string $dir Absolute path.
38 * @return bool
39 */
40 function openstation_desktop_theme_rmdir( $dir ) {
41 $dir = (string) $dir;
42 $base = realpath( openstation_desktop_themes_dir() );
43 $real = realpath( $dir );
44 if ( false === $base || false === $real ) {
45 return false;
46 }
47 if ( $real !== $base && 0 !== strpos( $real, $base . DIRECTORY_SEPARATOR ) ) {
48 return false;
49 }
50 if ( $real === $base ) {
51 // Never wipe the base itself.
52 return false;
53 }
54 if ( ! is_dir( $real ) ) {
55 return false;
56 }
57
58 $items = scandir( $real );
59 if ( false === $items ) {
60 return false;
61 }
62 foreach ( $items as $item ) {
63 if ( '.' === $item || '..' === $item ) {
64 continue;
65 }
66 $path = $real . '/' . $item;
67 if ( is_dir( $path ) && ! is_link( $path ) ) {
68 openstation_desktop_theme_rmdir( $path );
69 } else {
70 wp_delete_file( $path );
71 }
72 }
73 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir
74 return @rmdir( $real );
75 }
76
77 /**
78 * Whether a ZIP entry name should be ignored rather than rejected.
79 *
80 * `__MACOSX/` resource forks and dotfiles ride along in almost every
81 * archive a designer produces on a Mac. Failing the upload over them
82 * would be hostile; we simply never extract them.
83 *
84 * @internal
85 *
86 * @param string $name Entry name.
87 * @return bool
88 */
89 function openstation_desktop_theme_zip_entry_ignored( $name ) {
90 if ( 0 === strpos( $name, '__MACOSX/' ) ) {
91 return true;
92 }
93 foreach ( explode( '/', $name ) as $segment ) {
94 if ( '' === $segment ) {
95 continue;
96 }
97 if ( '.' === $segment[0] ) {
98 return true;
99 }
100 }
101 return false;
102 }
103
104 /**
105 * Validate an uploaded theme ZIP without writing anything.
106 *
107 * @param string $zip_path Absolute path of the uploaded archive.
108 * @return string|WP_Error The manifest's entry name on success
109 * (root-level or one directory deep),
110 * `WP_Error` otherwise.
111 */
112 function openstation_desktop_theme_validate_zip( $zip_path ) {
113 if ( ! class_exists( 'ZipArchive' ) ) {
114 return new WP_Error(
115 'openstation_desktop_theme_no_zip_support',
116 __( 'This server has no ZipArchive support, so theme uploads are unavailable.', 'desktop-mode' ),
117 array( 'status' => 501 )
118 );
119 }
120
121 $zip = new ZipArchive();
122 if ( true !== $zip->open( (string) $zip_path ) ) {
123 return new WP_Error(
124 'openstation_desktop_theme_bad_zip',
125 __( 'That file could not be read as a ZIP archive.', 'desktop-mode' ),
126 array( 'status' => 400 )
127 );
128 }
129
130 $caps = openstation_desktop_theme_zip_caps();
131 $extensions = array_flip( $caps['extensions'] );
132 $total = 0;
133 $counted = 0;
134 $manifests = array();
135
136 for ( $i = 0; $i < $zip->numFiles; $i++ ) {
137 $stat = $zip->statIndex( $i );
138 if ( ! is_array( $stat ) || ! isset( $stat['name'] ) ) {
139 $zip->close();
140 return new WP_Error(
141 'openstation_desktop_theme_bad_zip',
142 __( 'That archive contains an unreadable entry.', 'desktop-mode' ),
143 array( 'status' => 400 )
144 );
145 }
146 $name = (string) $stat['name'];
147
148 // Hard rejects — these are attacks, not accidents.
149 if ( false !== strpos( $name, "\0" ) || false !== strpos( $name, '\\' ) ) {
150 $zip->close();
151 return new WP_Error(
152 'openstation_desktop_theme_unsafe_entry',
153 __( 'That archive contains an unsafe file path.', 'desktop-mode' ),
154 array( 'status' => 400 )
155 );
156 }
157 if ( '' !== $name && ( '/' === $name[0] || preg_match( '~^[a-zA-Z]:~', $name ) ) ) {
158 $zip->close();
159 return new WP_Error(
160 'openstation_desktop_theme_unsafe_entry',
161 __( 'That archive contains an absolute file path.', 'desktop-mode' ),
162 array( 'status' => 400 )
163 );
164 }
165 foreach ( explode( '/', $name ) as $segment ) {
166 if ( '..' === $segment ) {
167 $zip->close();
168 return new WP_Error(
169 'openstation_desktop_theme_unsafe_entry',
170 __( 'That archive tries to write outside its own folder.', 'desktop-mode' ),
171 array( 'status' => 400 )
172 );
173 }
174 }
175
176 if ( openstation_desktop_theme_zip_entry_ignored( $name ) ) {
177 continue;
178 }
179 // Directory entry.
180 if ( '' === $name || '/' === substr( $name, -1 ) ) {
181 continue;
182 }
183
184 ++$counted;
185 if ( $counted > $caps['max_entries'] ) {
186 $zip->close();
187 return new WP_Error(
188 'openstation_desktop_theme_too_many_entries',
189 __( 'That theme archive contains too many files.', 'desktop-mode' ),
190 array( 'status' => 400 )
191 );
192 }
193
194 $size = isset( $stat['size'] ) ? (int) $stat['size'] : 0;
195 if ( $size > $caps['max_file'] ) {
196 $zip->close();
197 return new WP_Error(
198 'openstation_desktop_theme_entry_too_large',
199 sprintf(
200 /* translators: %s: file name inside the archive. */
201 __( '"%s" is larger than a theme asset is allowed to be.', 'desktop-mode' ),
202 $name
203 ),
204 array( 'status' => 400 )
205 );
206 }
207 $total += $size;
208 if ( $total > $caps['max_uncompressed'] ) {
209 $zip->close();
210 return new WP_Error(
211 'openstation_desktop_theme_archive_too_large',
212 __( 'That theme archive unpacks to more data than is allowed.', 'desktop-mode' ),
213 array( 'status' => 400 )
214 );
215 }
216
217 $ext = strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) );
218 if ( ! isset( $extensions[ $ext ] ) ) {
219 $zip->close();
220 return new WP_Error(
221 'openstation_desktop_theme_bad_extension',
222 sprintf(
223 /* translators: %s: file name inside the archive. */
224 __( '"%s" is not a file type a desktop theme may contain.', 'desktop-mode' ),
225 $name
226 ),
227 array( 'status' => 400 )
228 );
229 }
230
231 // Manifest candidates: `theme.json` at the archive root, or
232 // one directory deep (what "Compress this folder" produces).
233 if ( 'theme.json' === basename( $name ) ) {
234 $depth = substr_count( $name, '/' );
235 if ( $depth <= 1 ) {
236 $manifests[] = $name;
237 }
238 }
239 }
240
241 $zip->close();
242
243 if ( 1 !== count( $manifests ) ) {
244 return new WP_Error(
245 'openstation_desktop_theme_missing_manifest',
246 __( 'A theme archive must contain exactly one theme.json, at its root or in a single top-level folder.', 'desktop-mode' ),
247 array( 'status' => 400 )
248 );
249 }
250
251 return $manifests[0];
252 }
253
254 /**
255 * Strip everything scriptable out of an SVG file, in place.
256 *
257 * Uses DOMDocument with the network disabled and DTD/entity
258 * declarations rejected outright (billion-laughs / XXE). Removes
259 * script-bearing and embedding elements, every `on*` handler, any
260 * `href`/`xlink:href` that isn't a same-document fragment, and any
261 * `style` attribute containing `url(` or `javascript:`.
262 *
263 * When DOMDocument isn't available we **reject** the file rather
264 * than shipping unexamined SVG — the browser would happily run it.
265 *
266 * @param string $file Absolute path of the SVG.
267 * @return true|WP_Error
268 */
269 function openstation_desktop_theme_sanitize_svg( $file ) {
270 if ( ! class_exists( 'DOMDocument' ) ) {
271 return new WP_Error(
272 'openstation_desktop_theme_no_dom',
273 __( 'This server cannot sanitize SVG files, so SVG icons are not accepted here.', 'desktop-mode' ),
274 array( 'status' => 501 )
275 );
276 }
277
278 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
279 $markup = (string) file_get_contents( $file );
280 if ( '' === trim( $markup ) ) {
281 return new WP_Error(
282 'openstation_desktop_theme_bad_svg',
283 __( 'An SVG in that theme is empty.', 'desktop-mode' ),
284 array( 'status' => 400 )
285 );
286 }
287 // Refuse doctype / entity declarations before the parser ever
288 // sees them — cheapest possible XXE and billion-laughs defence.
289 if ( preg_match( '/<!DOCTYPE|<!ENTITY/i', $markup ) ) {
290 return new WP_Error(
291 'openstation_desktop_theme_bad_svg',
292 __( 'An SVG in that theme declares a DOCTYPE or entities, which is not allowed.', 'desktop-mode' ),
293 array( 'status' => 400 )
294 );
295 }
296
297 $previous = libxml_use_internal_errors( true );
298 $doc = new DOMDocument();
299 $loaded = $doc->loadXML( $markup, LIBXML_NONET | LIBXML_NOENT );
300 libxml_clear_errors();
301 libxml_use_internal_errors( $previous );
302
303 if ( ! $loaded || ! $doc->documentElement || 'svg' !== strtolower( $doc->documentElement->localName ) ) {
304 return new WP_Error(
305 'openstation_desktop_theme_bad_svg',
306 __( 'An SVG in that theme could not be parsed.', 'desktop-mode' ),
307 array( 'status' => 400 )
308 );
309 }
310
311 $forbidden = array( 'script', 'foreignobject', 'iframe', 'object', 'embed', 'audio', 'video', 'handler', 'set', 'animate' );
312
313 $walk = static function ( DOMNode $node ) use ( &$walk, $forbidden ) {
314 // Snapshot children first — we mutate while iterating.
315 $children = array();
316 foreach ( $node->childNodes as $child ) {
317 $children[] = $child;
318 }
319 foreach ( $children as $child ) {
320 if ( XML_PI_NODE === $child->nodeType ) {
321 $node->removeChild( $child );
322 continue;
323 }
324 if ( XML_ELEMENT_NODE !== $child->nodeType ) {
325 continue;
326 }
327 /** @var DOMElement $child */
328 $tag = strtolower( $child->localName );
329 if ( in_array( $tag, $forbidden, true ) ) {
330 $node->removeChild( $child );
331 continue;
332 }
333
334 $attributes = array();
335 foreach ( $child->attributes as $attribute ) {
336 $attributes[] = $attribute;
337 }
338 foreach ( $attributes as $attribute ) {
339 $name = strtolower( $attribute->nodeName );
340 $local = strtolower( $attribute->localName );
341 $value = (string) $attribute->nodeValue;
342
343 if ( 0 === strpos( $name, 'on' ) ) {
344 $child->removeAttributeNode( $attribute );
345 continue;
346 }
347 if ( 'href' === $local || 'xlink:href' === $name ) {
348 // Only same-document fragment references survive:
349 // no remote `<use>`, no `javascript:`, no data URI.
350 if ( '' === $value || '#' !== $value[0] ) {
351 $child->removeAttributeNode( $attribute );
352 }
353 continue;
354 }
355 if ( 'style' === $name && preg_match( '/url\s*\(|javascript\s*:|expression\s*\(/i', $value ) ) {
356 $child->removeAttributeNode( $attribute );
357 continue;
358 }
359 if ( preg_match( '/javascript\s*:/i', $value ) ) {
360 $child->removeAttributeNode( $attribute );
361 }
362 }
363
364 $walk( $child );
365 }
366 };
367 $walk( $doc );
368
369 $clean = $doc->saveXML();
370 if ( ! is_string( $clean ) || '' === $clean ) {
371 return new WP_Error(
372 'openstation_desktop_theme_bad_svg',
373 __( 'An SVG in that theme could not be re-serialized after sanitization.', 'desktop-mode' ),
374 array( 'status' => 400 )
375 );
376 }
377 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
378 file_put_contents( $file, $clean );
379
380 return true;
381 }
382
383 /**
384 * Delete abandoned staging directories.
385 *
386 * Every install path unwinds its own `.staging-<uuid>` dir, but a
387 * request that dies between `wp_mkdir_p()` and the cleanup — a fatal,
388 * an OOM kill, a host timeout — leaves one behind with nothing to
389 * collect it. At theme-upload frequency that is a slow leak rather
390 * than a problem, but it is a leak in a directory the web server
391 * serves, so it should not accumulate forever.
392 *
393 * **Swept here rather than on a hook.** An `init` sweep would put a
394 * `glob()` on every request in the site to clean up after an event
395 * that happens a few times in a plugin's life, and this module's whole
396 * posture is that an unused feature costs nothing. Sweeping at the top
397 * of an install runs it exactly when the directory is in use, at a
398 * moment already dominated by unzipping.
399 *
400 * The age floor matters: a CONCURRENT upload owns a staging dir that
401 * is seconds old, and deleting it would corrupt a live install.
402 *
403 * @internal
404 *
405 * @param int $max_age Seconds before an orphan is collectable.
406 * @return int Number of directories removed.
407 */
408 function openstation_desktop_theme_sweep_staging( $max_age = DAY_IN_SECONDS ) {
409 $base = openstation_desktop_themes_dir();
410 if ( ! is_dir( $base ) ) {
411 return 0;
412 }
413 $max_age = max( 60, (int) $max_age );
414 $now = time();
415 $removed = 0;
416
417 foreach ( (array) glob( $base . '/.staging-*', GLOB_ONLYDIR ) as $dir ) {
418 $mtime = @filemtime( $dir );
419 if ( false === $mtime || ( $now - $mtime ) < $max_age ) {
420 continue;
421 }
422 // `_rmdir()` refuses to act outside the themes base dir, so a
423 // symlinked or otherwise unexpected path cannot be followed out.
424 if ( openstation_desktop_theme_rmdir( $dir ) ) {
425 ++$removed;
426 }
427 }
428
429 return $removed;
430 }
431
432 /**
433 * Install (or update) a desktop theme from an uploaded ZIP.
434 *
435 * @param string $zip_path Absolute path of the uploaded archive.
436 * @return array|WP_Error The stored index entry on success.
437 */
438 function openstation_desktop_theme_install_from_zip( $zip_path ) {
439 // Collect anything a previously-killed install abandoned. Cheap,
440 // and this is the only moment the directory is guaranteed relevant.
441 openstation_desktop_theme_sweep_staging();
442
443 $manifest_entry = openstation_desktop_theme_validate_zip( $zip_path );
444 if ( is_wp_error( $manifest_entry ) ) {
445 return $manifest_entry;
446 }
447
448 $base = openstation_desktop_themes_ensure_dir();
449 if ( is_wp_error( $base ) ) {
450 return $base;
451 }
452
453 $staging = $base . '/.staging-' . wp_generate_uuid4();
454 if ( ! wp_mkdir_p( $staging ) ) {
455 return new WP_Error(
456 'openstation_desktop_theme_mkdir_failed',
457 __( 'Could not create a staging directory for the upload.', 'desktop-mode' ),
458 array( 'status' => 500 )
459 );
460 }
461
462 require_once ABSPATH . 'wp-admin/includes/file.php';
463 if ( ! WP_Filesystem() ) {
464 openstation_desktop_theme_rmdir( $staging );
465 return new WP_Error(
466 'openstation_desktop_theme_filesystem_unavailable',
467 __( 'WordPress could not access the filesystem to unpack the theme.', 'desktop-mode' ),
468 array( 'status' => 500 )
469 );
470 }
471 // Populated by `WP_Filesystem()` above. Used to move the manifest's
472 // assets into the live directory — the same transport `unzip_file()`
473 // just used to write them, so the two agree on non-direct setups.
474 global $wp_filesystem;
475
476 $unzipped = unzip_file( $zip_path, $staging );
477 if ( is_wp_error( $unzipped ) ) {
478 openstation_desktop_theme_rmdir( $staging );
479 // Surfaced verbatim: on FTP-credentialed filesystems this is
480 // the only signal that says WHY, and the generic message we
481 // could substitute would be strictly less useful.
482 return $unzipped;
483 }
484
485 // Re-root when the archive wrapped everything in one folder.
486 $root = $staging;
487 if ( false !== strpos( $manifest_entry, '/' ) ) {
488 $root = $staging . '/' . dirname( $manifest_entry );
489 }
490 $manifest_file = $root . '/theme.json';
491 if ( ! is_file( $manifest_file ) ) {
492 openstation_desktop_theme_rmdir( $staging );
493 return new WP_Error(
494 'openstation_desktop_theme_missing_manifest',
495 __( 'The archive unpacked without a theme.json.', 'desktop-mode' ),
496 array( 'status' => 400 )
497 );
498 }
499
500 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
501 $decoded = json_decode( (string) file_get_contents( $manifest_file ), true );
502 if ( null === $decoded ) {
503 openstation_desktop_theme_rmdir( $staging );
504 return new WP_Error(
505 'openstation_desktop_theme_bad_json',
506 __( 'theme.json is not valid JSON.', 'desktop-mode' ),
507 array( 'status' => 400 )
508 );
509 }
510
511 $manifest = openstation_sanitize_desktop_theme_manifest(
512 $decoded,
513 openstation_desktop_theme_staging_asset_resolver( $root )
514 );
515 if ( is_wp_error( $manifest ) ) {
516 openstation_desktop_theme_rmdir( $staging );
517 return $manifest;
518 }
519
520 $slug = (string) $manifest['slug'];
521
522 // Every asset the sanitized manifest actually references. Nothing
523 // else crosses into the live directory.
524 $assets = array();
525 if ( '' !== $manifest['preview'] ) {
526 $assets[ $manifest['preview'] ] = true;
527 }
528 foreach ( $manifest['icons'] as $icon ) {
529 if ( 'image' === $icon['type'] ) {
530 $assets[ $icon['path'] ] = true;
531 }
532 }
533 foreach ( $manifest['textures'] as $texture ) {
534 $assets[ $texture['path'] ] = true;
535 }
536 foreach ( $manifest['fonts'] as $face ) {
537 foreach ( $face['src'] as $source ) {
538 $assets[ $source['path'] ] = true;
539 }
540 }
541 foreach ( $manifest['wallpapers'] as $wallpaper ) {
542 if ( ! empty( $wallpaper['path'] ) ) {
543 $assets[ $wallpaper['path'] ] = true;
544 }
545 }
546
547 // Sanitize SVGs while they're still in staging. A failure here
548 // aborts the whole install — a theme that ships an SVG we can't
549 // make safe doesn't get to install with that icon quietly dropped.
550 foreach ( array_keys( $assets ) as $relative ) {
551 if ( 'svg' !== strtolower( (string) pathinfo( $relative, PATHINFO_EXTENSION ) ) ) {
552 continue;
553 }
554 $sanitized = openstation_desktop_theme_sanitize_svg( $root . '/' . $relative );
555 if ( is_wp_error( $sanitized ) ) {
556 openstation_desktop_theme_rmdir( $staging );
557 return $sanitized;
558 }
559 }
560
561 // Re-upload of the same id is an UPDATE: drop the old directory
562 // wholesale so removed assets don't linger.
563 $target = openstation_desktop_themes_dir( $slug );
564 if ( is_dir( $target ) ) {
565 openstation_desktop_theme_rmdir( $target );
566 }
567 if ( ! wp_mkdir_p( $target ) ) {
568 openstation_desktop_theme_rmdir( $staging );
569 return new WP_Error(
570 'openstation_desktop_theme_mkdir_failed',
571 __( 'Could not create the theme directory.', 'desktop-mode' ),
572 array( 'status' => 500 )
573 );
574 }
575
576 foreach ( array_keys( $assets ) as $relative ) {
577 $destination = $target . '/' . $relative;
578 $dir = dirname( $destination );
579 if ( ! wp_mkdir_p( $dir ) ) {
580 openstation_desktop_theme_rmdir( $staging );
581 openstation_desktop_theme_rmdir( $target );
582 return new WP_Error(
583 'openstation_desktop_theme_mkdir_failed',
584 __( 'Could not create a theme asset directory.', 'desktop-mode' ),
585 array( 'status' => 500 )
586 );
587 }
588 // `WP_Filesystem::move()` rather than `rename()`: it is the
589 // documented API, it works on the non-direct transports the
590 // extract above already went through, and its Direct
591 // implementation falls back to copy-then-delete when a plain
592 // rename fails (staging and uploads landing on different
593 // devices is the common case). `true` overwrites — the target
594 // directory was just recreated, so nothing should be there,
595 // and a stale file must not silently abort the install.
596 if ( ! $wp_filesystem->move( $root . '/' . $relative, $destination, true ) ) {
597 openstation_desktop_theme_rmdir( $staging );
598 openstation_desktop_theme_rmdir( $target );
599 return new WP_Error(
600 'openstation_desktop_theme_write_failed',
601 __( 'Could not move a theme asset into place.', 'desktop-mode' ),
602 array( 'status' => 500 )
603 );
604 }
605 }
606
607 // Keep the author's manifest next to the compiled CSS. It is
608 // never read back at runtime — the sanitized copy in the option
609 // is what the shell sees — but it makes the installed directory
610 // self-describing for anyone debugging a theme.
611 // Same reasoning as the move above — the documented API rather than
612 // a raw `copy()`. Failure is deliberately not fatal: this file is
613 // a debugging convenience, never read back at runtime.
614 $wp_filesystem->copy( $manifest_file, $target . '/theme.json', true );
615
616 // One timestamp for the compile AND the index entry: it is the
617 // cache-buster stamped onto every asset URL the stylesheet
618 // references, so the two must agree or a re-upload's textures go
619 // stale while its CSS refreshes.
620 $installed_at = time();
621
622 $css = openstation_desktop_theme_compile_css(
623 $manifest,
624 $slug,
625 openstation_desktop_themes_url( $slug ),
626 (string) $installed_at
627 );
628 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
629 if ( false === file_put_contents( $target . '/theme.css', $css ) ) {
630 openstation_desktop_theme_rmdir( $staging );
631 openstation_desktop_theme_rmdir( $target );
632 return new WP_Error(
633 'openstation_desktop_theme_write_failed',
634 __( 'Could not write the compiled theme stylesheet.', 'desktop-mode' ),
635 array( 'status' => 500 )
636 );
637 }
638
639 openstation_desktop_theme_rmdir( $staging );
640
641 $entry = array(
642 'slug' => $slug,
643 'manifest' => $manifest,
644 'installedAt' => $installed_at,
645 'installedBy' => get_current_user_id(),
646 );
647
648 $index = openstation_desktop_themes_index();
649 $index[ $slug ] = $entry;
650 openstation_desktop_themes_put_index( $index );
651
652 /**
653 * Fires after a desktop theme has been installed or updated.
654 *
655 * @param string $slug Theme slug.
656 * @param array $entry Stored index entry (`slug`, `manifest`,
657 * `installedAt`, `installedBy`).
658 */
659 do_action( 'openstation_desktop_theme_installed', $slug, $entry );
660
661 return $entry;
662 }
663
664 /**
665 * Delete an installed desktop theme (directory + index entry).
666 *
667 * Users whose selection pointed at the deleted theme degrade
668 * silently to the system default — the enqueue path checks the
669 * index on every request, so no user meta needs rewriting.
670 *
671 * @param string $slug Theme slug.
672 * @return true|WP_Error
673 */
674 function openstation_desktop_theme_delete( $slug ) {
675 $slug = sanitize_key( (string) $slug );
676 $index = openstation_desktop_themes_index();
677 if ( '' === $slug || ! isset( $index[ $slug ] ) ) {
678 return new WP_Error(
679 'openstation_desktop_theme_not_found',
680 __( 'That desktop theme is not installed.', 'desktop-mode' ),
681 array( 'status' => 404 )
682 );
683 }
684
685 $entry = $index[ $slug ];
686 $dir = openstation_desktop_themes_dir( $slug );
687 if ( is_dir( $dir ) ) {
688 openstation_desktop_theme_rmdir( $dir );
689 }
690 unset( $index[ $slug ] );
691 openstation_desktop_themes_put_index( $index );
692
693 /**
694 * Fires after a desktop theme has been deleted.
695 *
696 * @param string $slug Theme slug.
697 * @param array $entry The index entry as it was before removal.
698 */
699 do_action( 'openstation_desktop_theme_deleted', $slug, $entry );
700
701 return true;
702 }
703