PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.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 1.1.7, at includes/desktop-files/rest-uploads.php

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