PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.7
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-files / rest-uploads.php

rest-uploads.php in OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin 0.9.7, at includes/desktop-files/rest-uploads.php

643 lines 20.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — upload REST intake.
4 *
5 * One route:
6 *
7 * POST /desktop-mode/v1/files/uploads
8 * multipart/form-data with ONE file part (`file`) plus:
9 * `parentId` target folder id (0 = desktop root)
10 * `relativePath` optional `a/b/c.txt`-style path from a
11 * folder-tree upload; the server resolves the
12 * directory segments to folder rows mkdir-p
13 * style (deduped by parent + name)
14 * `x`, `y` optional tile coordinates (root drops)
15 *
16 * One file per request on purpose: per-file retry, per-file
17 * progress, and no interaction with `max_file_uploads` /
18 * `post_max_size` aggregates. Internally the handler is split into
19 * receive (bytes → disk) and register (row + placement) so a future
20 * resumable-upload layer can feed the same register step.
21 *
22 * @package WPDesktopMode
23 * @since 0.9.6
24 */
25
26 defined( 'ABSPATH' ) || exit;
27
28 /**
29 * Extensions that must never be accepted regardless of the MIME
30 * policy — server- or config-executable files. Matched against
31 * EVERY dot-segment of the client filename (`shell.php.gif` is
32 * rejected even though its final extension is fine), per the
33 * OWASP double-extension guidance. Belt and suspenders: the WP
34 * MIME policy would reject most of these anyway, and the stored
35 * disk name is an extensionless UUID that no handler dispatches.
36 *
37 * @since 0.9.6
38 *
39 * @return string[]
40 */
41 function desktop_mode_stored_files_denied_extensions() {
42 $denied = array(
43 'php', 'php3', 'php4', 'php5', 'php7', 'php8',
44 'phtml', 'phar', 'pht', 'phps',
45 'cgi', 'pl', 'asp', 'aspx', 'jsp', 'shtml',
46 );
47 /**
48 * Filters the hard-denied extension list. Narrowing this below
49 * the shipped set is strongly discouraged.
50 *
51 * @since 0.9.6
52 *
53 * @param string[] $denied Lowercase extensions.
54 */
55 return (array) apply_filters( 'desktop_mode_stored_files_denied_extensions', $denied );
56 }
57
58 /**
59 * Whole-filename denylist (dotfiles / server config).
60 *
61 * @since 0.9.6
62 *
63 * @param string $name Client filename.
64 * @return bool True when the name is forbidden.
65 */
66 function desktop_mode_stored_files_is_denied_filename( $name ) {
67 $name = strtolower( trim( (string) $name ) );
68 if ( in_array( $name, array( '.htaccess', '.user.ini', 'web.config' ), true ) ) {
69 return true;
70 }
71 $segments = explode( '.', $name );
72 array_shift( $segments ); // Everything after the first dot is an "extension" segment.
73 $denied = desktop_mode_stored_files_denied_extensions();
74 foreach ( $segments as $segment ) {
75 if ( in_array( $segment, $denied, true ) ) {
76 return true;
77 }
78 }
79 return false;
80 }
81
82 /**
83 * Effective per-file size cap in bytes.
84 *
85 * @since 0.9.6
86 *
87 * @param int $user_id User.
88 * @return int
89 */
90 function desktop_mode_stored_files_max_upload_bytes( $user_id ) {
91 $max = (int) wp_max_upload_size();
92 /**
93 * Filters the per-file upload cap for desktop storage. May only
94 * effectively lower it below the server limits — PHP discards
95 * larger bodies before WordPress runs.
96 *
97 * @since 0.9.6
98 *
99 * @param int $max Cap in bytes. Default `wp_max_upload_size()`.
100 * @param int $user_id User.
101 */
102 return max( 0, (int) apply_filters( 'desktop_mode_stored_files_max_upload_bytes', $max, (int) $user_id ) );
103 }
104
105 /**
106 * Permission: base files gate + the upload capability.
107 *
108 * @since 0.9.6
109 */
110 function desktop_mode_files_rest_uploads_permission() {
111 $base = desktop_mode_files_rest_permission();
112 if ( is_wp_error( $base ) ) {
113 return $base;
114 }
115 if ( ! current_user_can( desktop_mode_stored_files_upload_capability() ) ) {
116 return new WP_Error(
117 'desktop_mode_stored_files_cannot_upload',
118 __( 'You are not allowed to upload files.', 'desktop-mode' ),
119 array( 'status' => 403 )
120 );
121 }
122 return true;
123 }
124
125 /**
126 * Register the upload route.
127 *
128 * @since 0.9.6
129 */
130 function desktop_mode_files_register_upload_rest_routes() {
131 register_rest_route( 'desktop-mode/v1', '/files/uploads', array(
132 // POST only — PHP parses multipart into $_FILES for real
133 // POST requests exclusively.
134 'methods' => WP_REST_Server::CREATABLE,
135 'permission_callback' => 'desktop_mode_files_rest_uploads_permission',
136 'callback' => 'desktop_mode_files_rest_upload',
137 'args' => array(
138 'parentId' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint' ),
139 'relativePath' => array( 'type' => 'string', 'default' => '' ),
140 // No defaults on x/y on purpose: absent coords mean
141 // "server picks the next free grid slot".
142 'x' => array( 'type' => 'integer', 'required' => false ),
143 'y' => array( 'type' => 'integer', 'required' => false ),
144 ),
145 ) );
146
147 register_rest_route( 'desktop-mode/v1', '/files/uploads/paths', array(
148 'methods' => WP_REST_Server::CREATABLE,
149 'permission_callback' => 'desktop_mode_files_rest_uploads_permission',
150 'callback' => 'desktop_mode_files_rest_ensure_upload_path',
151 'args' => array(
152 'parentId' => array( 'type' => 'integer', 'default' => 0, 'sanitize_callback' => 'absint' ),
153 'relativePath' => array( 'type' => 'string', 'required' => true ),
154 ),
155 ) );
156
157 register_rest_route( 'desktop-mode/v1', '/files/uploads/(?P<id>\d+)', array(
158 'methods' => WP_REST_Server::EDITABLE,
159 'permission_callback' => 'desktop_mode_files_rest_permission',
160 'callback' => 'desktop_mode_files_rest_rename_upload',
161 'args' => array(
162 'name' => array( 'type' => 'string', 'required' => true ),
163 ),
164 ) );
165 }
166
167 /**
168 * POST /files/uploads/paths — mkdir-p a directory path with no
169 * file attached. Used by folder-tree drops to preserve EMPTY
170 * directories (the drag-drop Entries API sees them; files-only
171 * transports lose them).
172 *
173 * @since 0.9.6
174 *
175 * @param WP_REST_Request $req Request.
176 * @return WP_REST_Response|WP_Error
177 */
178 function desktop_mode_files_rest_ensure_upload_path( WP_REST_Request $req ) {
179 $rel = (string) $req->get_param( 'relativePath' );
180 if ( '' === trim( $rel, " \t/" ) ) {
181 return new WP_Error( 'desktop_mode_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
182 }
183 if ( '/' !== substr( $rel, -1 ) ) {
184 $rel .= '/'; // Whole string is a directory path.
185 }
186 $folder_id = desktop_mode_files_resolve_relative_path(
187 get_current_user_id(),
188 (int) $req->get_param( 'parentId' ),
189 $rel
190 );
191 if ( is_wp_error( $folder_id ) ) {
192 return $folder_id;
193 }
194 return rest_ensure_response( array( 'folderId' => (int) $folder_id ) );
195 }
196
197 /**
198 * PATCH /files/uploads/<id> — rename (owner only). Not-found and
199 * not-owner are both 404 (existence masking, same as downloads).
200 *
201 * @since 0.9.6
202 *
203 * @param WP_REST_Request $req Request.
204 * @return WP_REST_Response|WP_Error
205 */
206 function desktop_mode_files_rest_rename_upload( WP_REST_Request $req ) {
207 $file_id = (int) $req['id'];
208 $user_id = get_current_user_id();
209 $row = desktop_mode_stored_files_get( $file_id );
210 if ( ! $row || (int) $row['owner_id'] !== $user_id ) {
211 return desktop_mode_files_download_not_found();
212 }
213 $ok = desktop_mode_stored_files_rename( $file_id, (string) $req->get_param( 'name' ) );
214 if ( is_wp_error( $ok ) ) {
215 return $ok;
216 }
217 $row = desktop_mode_stored_files_get( $file_id );
218 return rest_ensure_response(
219 array(
220 'id' => (int) $row['id'],
221 'name' => (string) $row['display_name'],
222 'sizeBytes' => (int) $row['size_bytes'],
223 'mime' => (string) $row['mime'],
224 )
225 );
226 }
227 add_action( 'rest_api_init', 'desktop_mode_files_register_upload_rest_routes' );
228
229 /**
230 * POST /files/uploads
231 *
232 * @since 0.9.6
233 *
234 * @param WP_REST_Request $req Request.
235 * @return WP_REST_Response|WP_Error
236 */
237 function desktop_mode_files_rest_upload( WP_REST_Request $req ) {
238 $user_id = get_current_user_id();
239 $files = $req->get_file_params();
240
241 // A body larger than `post_max_size` reaches PHP as a paramless
242 // request: $_POST and $_FILES both empty while CONTENT_LENGTH
243 // says bytes were sent. Answer a clear 413 instead of the
244 // baffling "missing parameter" default.
245 if ( empty( $files ) ) {
246 $content_length = isset( $_SERVER['CONTENT_LENGTH'] ) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
247 if ( $content_length > 0 ) {
248 return new WP_Error(
249 'desktop_mode_stored_files_too_large',
250 __( 'That file is larger than this server accepts.', 'desktop-mode' ),
251 array( 'status' => 413 )
252 );
253 }
254 return new WP_Error(
255 'desktop_mode_stored_files_no_file',
256 __( 'No file was uploaded.', 'desktop-mode' ),
257 array( 'status' => 400 )
258 );
259 }
260 if ( empty( $files['file'] ) || ! is_array( $files['file'] ) ) {
261 return new WP_Error(
262 'desktop_mode_stored_files_no_file',
263 __( 'No file was uploaded.', 'desktop-mode' ),
264 array( 'status' => 400 )
265 );
266 }
267
268 $received = desktop_mode_files_upload_receive( $files['file'], $user_id );
269 if ( is_wp_error( $received ) ) {
270 return $received;
271 }
272
273 $x = $req->get_param( 'x' );
274 $y = $req->get_param( 'y' );
275 $coords = ( null !== $x && null !== $y )
276 ? array(
277 'x' => (int) $x,
278 'y' => (int) $y,
279 )
280 : null;
281
282 $registered = desktop_mode_files_upload_register(
283 $user_id,
284 $received,
285 (int) $req->get_param( 'parentId' ),
286 (string) $req->get_param( 'relativePath' ),
287 $coords
288 );
289 if ( is_wp_error( $registered ) ) {
290 // Bytes are already on disk; don't leak them.
291 if ( ! empty( $received['path'] ) && file_exists( $received['path'] ) ) {
292 wp_delete_file( $received['path'] );
293 }
294 return $registered;
295 }
296
297 $row = desktop_mode_files_get_placement( $registered['placement_id'] );
298 return rest_ensure_response(
299 array(
300 'placement' => desktop_mode_files_shape_placement( $row ),
301 'storedFileId' => (int) $registered['file_id'],
302 )
303 );
304 }
305
306 /**
307 * Receive step: validate and move the bytes into the owner's
308 * storage dir under a fresh UUID disk name. Returns
309 * `{ path, disk_name, display_name, size_bytes, mime }` or an
310 * error. No DB writes happen here.
311 *
312 * @since 0.9.6
313 * @internal
314 *
315 * @param array $file Single `$_FILES`-shaped entry.
316 * @param int $user_id Uploader.
317 * @return array|WP_Error
318 */
319 function desktop_mode_files_upload_receive( $file, $user_id ) {
320 $user_id = (int) $user_id;
321 $client_name = isset( $file['name'] ) ? (string) $file['name'] : '';
322
323 if ( desktop_mode_stored_files_is_denied_filename( $client_name ) ) {
324 return new WP_Error(
325 'desktop_mode_stored_files_forbidden_type',
326 __( 'This file type is not allowed.', 'desktop-mode' ),
327 array( 'status' => 400 )
328 );
329 }
330
331 $size = isset( $file['size'] ) ? (int) $file['size'] : 0;
332 $max = desktop_mode_stored_files_max_upload_bytes( $user_id );
333 if ( $max > 0 && $size > $max ) {
334 return new WP_Error(
335 'desktop_mode_stored_files_too_large',
336 sprintf(
337 /* translators: %s: formatted maximum file size. */
338 __( 'That file is larger than the allowed maximum of %s.', 'desktop-mode' ),
339 size_format( $max )
340 ),
341 array( 'status' => 413 )
342 );
343 }
344
345 $quota = desktop_mode_stored_files_user_quota_bytes( $user_id );
346 if ( $quota > 0 && ( desktop_mode_stored_files_total_bytes( $user_id ) + $size ) > $quota ) {
347 return new WP_Error(
348 'desktop_mode_stored_files_quota_exceeded',
349 __( 'Your desktop storage is full.', 'desktop-mode' ),
350 array( 'status' => 403 )
351 );
352 }
353
354 $dir = desktop_mode_stored_files_ensure_dir( $user_id );
355 if ( is_wp_error( $dir ) ) {
356 return $dir;
357 }
358
359 require_once ABSPATH . 'wp-admin/includes/file.php';
360
361 /**
362 * Filters the `ext => mime` allowlist for desktop-storage
363 * uploads. Defaults to the user-scoped WordPress policy.
364 * Additions here genuinely widen the policy (the scoped
365 * `upload_mimes` hook below keeps core's re-check in
366 * `wp_check_filetype_and_ext()` in agreement).
367 *
368 * @since 0.9.6
369 *
370 * @param array<string,string> $mimes Allowed map.
371 * @param int $user_id Uploader.
372 */
373 $mimes = (array) apply_filters(
374 'desktop_mode_stored_files_allowed_mimes',
375 get_allowed_mime_types( $user_id ),
376 $user_id
377 );
378
379 $disk_name = wp_generate_uuid4();
380 $scoped_mime = static function () use ( $mimes ) {
381 return $mimes;
382 };
383 $name_cb = static function ( $dir_unused, $name_unused, $ext_unused ) use ( $disk_name ) {
384 return $disk_name;
385 };
386 // Resolve the target paths BEFORE hooking `upload_dir` and close
387 // over plain strings — `desktop_mode_stored_files_dir()` calls
388 // `wp_get_upload_dir()`, which applies the `upload_dir` filter,
389 // so calling it from inside the closure would recurse infinitely.
390 $target_base = desktop_mode_stored_files_dir();
391 $target_dir = desktop_mode_stored_files_dir( $user_id );
392 $redirect = static function ( $dirs ) use ( $user_id, $target_base, $target_dir ) {
393 $dirs['subdir'] = '/' . $user_id;
394 $dirs['path'] = $target_dir;
395 $dirs['url'] = $dirs['baseurl'] . '/desktop-mode-files/' . $user_id;
396 $dirs['basedir'] = $target_base;
397 $dirs['baseurl'] = $dirs['baseurl'] . '/desktop-mode-files';
398 return $dirs;
399 };
400
401 /**
402 * Filters the `wp_handle_upload()` overrides for desktop-storage
403 * uploads. Exists mainly so tests (and future resumable layers
404 * feeding pre-staged files) can switch `action` to the sideload
405 * variant — never remove `test_form => false`.
406 *
407 * @since 0.9.6
408 *
409 * @param array $overrides Overrides array.
410 * @param int $user_id Uploader.
411 */
412 $overrides = (array) apply_filters(
413 'desktop_mode_stored_files_upload_overrides',
414 array(
415 'test_form' => false,
416 'mimes' => $mimes,
417 'unique_filename_callback' => $name_cb,
418 ),
419 $user_id
420 );
421
422 add_filter( 'upload_dir', $redirect );
423 add_filter( 'upload_mimes', $scoped_mime );
424 $result = wp_handle_upload( $file, $overrides );
425 remove_filter( 'upload_mimes', $scoped_mime );
426 remove_filter( 'upload_dir', $redirect );
427
428 if ( isset( $result['error'] ) ) {
429 return new WP_Error(
430 'desktop_mode_stored_files_upload_failed',
431 (string) $result['error'],
432 array( 'status' => 400 )
433 );
434 }
435
436 $path = (string) $result['file'];
437 return array(
438 'path' => $path,
439 'disk_name' => $disk_name,
440 'display_name' => sanitize_file_name( $client_name ),
441 'size_bytes' => (int) @filesize( $path ),
442 'mime' => (string) $result['type'],
443 );
444 }
445
446 /**
447 * Register step: stored-file row + folder resolution + placement.
448 *
449 * @since 0.9.6
450 * @internal
451 *
452 * @param int $user_id Uploader.
453 * @param array $received Return value of the receive step.
454 * @param int $parent_id Base target folder (0 = root).
455 * @param string $relative_path Optional `a/b/c.ext` path; directory
456 * segments are resolved under `$parent_id`.
457 * @param array|null $coords `x`, `y` for the placement, or null
458 * to auto-place at the next free slot.
459 * @return array|WP_Error `{ file_id, placement_id }`.
460 */
461 function desktop_mode_files_upload_register( $user_id, $received, $parent_id, $relative_path = '', $coords = null ) {
462 $parent_id = max( 0, (int) $parent_id );
463
464 if ( '' !== (string) $relative_path ) {
465 $resolved = desktop_mode_files_resolve_relative_path( (int) $user_id, $parent_id, (string) $relative_path );
466 if ( is_wp_error( $resolved ) ) {
467 return $resolved;
468 }
469 $parent_id = $resolved;
470 }
471
472 $file_id = desktop_mode_stored_files_create(
473 (int) $user_id,
474 array(
475 'display_name' => $received['display_name'],
476 'disk_name' => $received['disk_name'],
477 'size_bytes' => $received['size_bytes'],
478 'mime' => $received['mime'],
479 )
480 );
481 if ( is_wp_error( $file_id ) ) {
482 return $file_id;
483 }
484
485 if ( is_array( $coords ) && isset( $coords['x'], $coords['y'] ) ) {
486 $placement_id = desktop_mode_files_place(
487 (int) $user_id,
488 $parent_id,
489 'upload',
490 (string) $file_id,
491 array(
492 'x' => (int) $coords['x'],
493 'y' => (int) $coords['y'],
494 )
495 );
496 } else {
497 // No coords sent (folder-tree members, batch files after
498 // the first) — the server picks the next free grid slot so
499 // tiles never stack at the origin.
500 $placement_id = desktop_mode_files_place_at_next_free_slot(
501 (int) $user_id,
502 $parent_id,
503 'upload',
504 (string) $file_id
505 );
506 }
507 if ( is_wp_error( $placement_id ) ) {
508 // Roll back through the store primitive so the documented
509 // `desktop_mode_stored_file_created` / `_deleted` action pair
510 // stays balanced for subscribers (and the bytes go with the
511 // row — the caller's outer cleanup guard becomes a no-op).
512 desktop_mode_stored_files_delete( (int) $file_id );
513 return $placement_id;
514 }
515
516 /**
517 * Fires after an upload lands (bytes, row, and placement all
518 * exist).
519 *
520 * @since 0.9.6
521 *
522 * @param int $file_id Stored-file id.
523 * @param int $placement_id Placement id.
524 * @param int $user_id Uploader.
525 */
526 do_action( 'desktop_mode_stored_file_uploaded', (int) $file_id, (int) $placement_id, (int) $user_id );
527
528 return array(
529 'file_id' => (int) $file_id,
530 'placement_id' => (int) $placement_id,
531 );
532 }
533
534 /**
535 * Resolve the DIRECTORY part of `a/b/c.ext` to a folder id under
536 * `$base_parent_id`, creating folder rows + placements mkdir-p
537 * style. Existing folders are reused when the acting user already
538 * has a live folder of that name in that parent (dedupe — parallel
539 * uploads of one tree share segments instead of racing).
540 *
541 * The final path segment is the FILE name and is ignored here.
542 *
543 * @since 0.9.6
544 *
545 * @param int $user_id Acting user.
546 * @param int $base_parent_id Folder to resolve under (0 = root).
547 * @param string $relative_path `a/b/c.ext` or `a/b/` (trailing
548 * slash = pure directory path, e.g.
549 * an empty folder from a drag).
550 * @return int|WP_Error Folder id to place the file in.
551 */
552 function desktop_mode_files_resolve_relative_path( $user_id, $base_parent_id, $relative_path ) {
553 global $wpdb;
554 $user_id = (int) $user_id;
555 $parent_id = max( 0, (int) $base_parent_id );
556 $path = str_replace( '\\', '/', (string) $relative_path );
557
558 if ( false !== strpos( $path, "\0" ) ) {
559 return new WP_Error( 'desktop_mode_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
560 }
561
562 $is_dir_path = '/' === substr( $path, -1 );
563 $segments = array_values( array_filter( explode( '/', $path ), 'strlen' ) );
564 if ( ! $is_dir_path ) {
565 array_pop( $segments ); // Last segment is the file name.
566 }
567 if ( empty( $segments ) ) {
568 return $parent_id;
569 }
570 if ( count( $segments ) > 32 ) {
571 return new WP_Error( 'desktop_mode_stored_files_path_too_deep', __( 'That folder tree is nested too deeply.', 'desktop-mode' ), array( 'status' => 400 ) );
572 }
573
574 $tables = desktop_mode_files_table_names();
575 foreach ( $segments as $segment ) {
576 if ( '.' === $segment || '..' === $segment ) {
577 return new WP_Error( 'desktop_mode_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
578 }
579 $name = sanitize_file_name( wp_strip_all_tags( $segment ) );
580 if ( '' === $name ) {
581 return new WP_Error( 'desktop_mode_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
582 }
583
584 // Dedupe: a live folder of this name, placed in this parent,
585 // owned by the acting user.
586 $existing = $wpdb->get_var(
587 $wpdb->prepare(
588 "SELECT f.id FROM {$tables['folders']} f
589 INNER JOIN {$tables['placements']} p
590 ON p.file_type = 'folder'
591 AND p.file_ref = CAST( f.id AS CHAR )
592 AND p.trashed_at_ms IS NULL
593 WHERE p.parent_id = %d
594 AND p.owner_id = %d
595 AND f.owner_id = %d
596 AND f.trashed_at_ms IS NULL
597 AND f.name = %s
598 LIMIT 1",
599 $parent_id,
600 $user_id,
601 $user_id,
602 $name
603 )
604 );
605 if ( $existing ) {
606 $parent_id = (int) $existing;
607 continue;
608 }
609
610 $folder_id = desktop_mode_files_create_folder( $user_id, array( 'name' => $name ) );
611 if ( is_wp_error( $folder_id ) ) {
612 return $folder_id;
613 }
614 $placement = desktop_mode_files_place( $user_id, $parent_id, 'folder', (string) $folder_id );
615 if ( is_wp_error( $placement ) ) {
616 return $placement;
617 }
618 $parent_id = (int) $folder_id;
619 }
620 return $parent_id;
621 }
622
623 /**
624 * Shell-config injection: what the client upload/download UX needs
625 * to know up front.
626 *
627 * @since 0.9.6
628 *
629 * @param array $config Shell config.
630 * @return array
631 */
632 function desktop_mode_stored_files_inject_shell_config( $config ) {
633 $user_id = get_current_user_id();
634 $config['desktopStorage'] = array(
635 'canUpload' => $user_id > 0 && current_user_can( desktop_mode_stored_files_upload_capability() ),
636 'maxBytes' => desktop_mode_stored_files_max_upload_bytes( $user_id ),
637 'quotaBytes' => desktop_mode_stored_files_user_quota_bytes( $user_id ),
638 'zipAvailable' => class_exists( 'ZipArchive' ),
639 );
640 return $config;
641 }
642 add_filter( 'desktop_mode_shell_config', 'desktop_mode_stored_files_inject_shell_config', 20 );
643