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

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

658 lines 20.2 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 $folder_id = openstation_files_resolve_relative_path(
218 get_current_user_id(),
219 (int) $req->get_param( 'parentId' ),
220 $rel
221 );
222 if ( is_wp_error( $folder_id ) ) {
223 return $folder_id;
224 }
225 return rest_ensure_response( array( 'folderId' => (int) $folder_id ) );
226 }
227
228 /**
229 * PATCH /files/uploads/<id> — rename (owner only). Not-found and
230 * not-owner are both 404 (existence masking, same as downloads).
231 *
232 * @param WP_REST_Request $req Request.
233 * @return WP_REST_Response|WP_Error
234 */
235 function openstation_files_rest_rename_upload( WP_REST_Request $req ) {
236 $file_id = (int) $req['id'];
237 $user_id = get_current_user_id();
238 $row = openstation_stored_files_get( $file_id );
239 if ( ! $row || (int) $row['owner_id'] !== $user_id ) {
240 return openstation_files_download_not_found();
241 }
242 $ok = openstation_stored_files_rename( $file_id, (string) $req->get_param( 'name' ) );
243 if ( is_wp_error( $ok ) ) {
244 return $ok;
245 }
246 $row = openstation_stored_files_get( $file_id );
247 return rest_ensure_response(
248 array(
249 'id' => (int) $row['id'],
250 'name' => (string) $row['display_name'],
251 'sizeBytes' => (int) $row['size_bytes'],
252 'mime' => (string) $row['mime'],
253 )
254 );
255 }
256 add_action( 'rest_api_init', 'openstation_files_register_upload_rest_routes' );
257
258 /**
259 * POST /files/uploads
260 *
261 * @param WP_REST_Request $req Request.
262 * @return WP_REST_Response|WP_Error
263 */
264 function openstation_files_rest_upload( WP_REST_Request $req ) {
265 $user_id = get_current_user_id();
266 $files = $req->get_file_params();
267
268 // A body larger than `post_max_size` reaches PHP as a paramless
269 // request: $_POST and $_FILES both empty while CONTENT_LENGTH
270 // says bytes were sent. Answer a clear 413 instead of the
271 // baffling "missing parameter" default.
272 if ( empty( $files ) ) {
273 $content_length = isset( $_SERVER['CONTENT_LENGTH'] ) ? (int) $_SERVER['CONTENT_LENGTH'] : 0;
274 if ( $content_length > 0 ) {
275 return new WP_Error(
276 'openstation_stored_files_too_large',
277 __( 'That file is larger than this server accepts.', 'desktop-mode' ),
278 array( 'status' => 413 )
279 );
280 }
281 return new WP_Error(
282 'openstation_stored_files_no_file',
283 __( 'No file was uploaded.', 'desktop-mode' ),
284 array( 'status' => 400 )
285 );
286 }
287 if ( empty( $files['file'] ) || ! is_array( $files['file'] ) ) {
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
295 $received = openstation_files_upload_receive( $files['file'], $user_id );
296 if ( is_wp_error( $received ) ) {
297 return $received;
298 }
299
300 $x = $req->get_param( 'x' );
301 $y = $req->get_param( 'y' );
302 $coords = ( null !== $x && null !== $y )
303 ? array(
304 'x' => (int) $x,
305 'y' => (int) $y,
306 )
307 : null;
308
309 $registered = openstation_files_upload_register(
310 $user_id,
311 $received,
312 (int) $req->get_param( 'parentId' ),
313 (string) $req->get_param( 'relativePath' ),
314 $coords
315 );
316 if ( is_wp_error( $registered ) ) {
317 // Bytes are already on disk; don't leak them.
318 if ( ! empty( $received['path'] ) && file_exists( $received['path'] ) ) {
319 wp_delete_file( $received['path'] );
320 }
321 return $registered;
322 }
323
324 $row = openstation_files_get_placement( $registered['placement_id'] );
325 return rest_ensure_response(
326 array(
327 'placement' => openstation_files_shape_placement( $row ),
328 'storedFileId' => (int) $registered['file_id'],
329 )
330 );
331 }
332
333 /**
334 * Receive step: validate and move the bytes into the owner's
335 * storage dir under a fresh UUID disk name. Returns
336 * `{ path, disk_name, display_name, size_bytes, mime }` or an
337 * error. No DB writes happen here.
338 *
339 * @internal
340 *
341 * @param array $file Single `$_FILES`-shaped entry.
342 * @param int $user_id Uploader.
343 * @return array|WP_Error
344 */
345 function openstation_files_upload_receive( $file, $user_id ) {
346 $user_id = (int) $user_id;
347 $client_name = isset( $file['name'] ) ? (string) $file['name'] : '';
348
349 if ( openstation_stored_files_is_denied_filename( $client_name ) ) {
350 return new WP_Error(
351 'openstation_stored_files_forbidden_type',
352 __( 'This file type is not allowed.', 'desktop-mode' ),
353 array( 'status' => 400 )
354 );
355 }
356
357 $size = isset( $file['size'] ) ? (int) $file['size'] : 0;
358 $max = openstation_stored_files_max_upload_bytes( $user_id );
359 if ( $max > 0 && $size > $max ) {
360 return new WP_Error(
361 'openstation_stored_files_too_large',
362 sprintf(
363 /* translators: %s: formatted maximum file size. */
364 __( 'That file is larger than the allowed maximum of %s.', 'desktop-mode' ),
365 size_format( $max )
366 ),
367 array( 'status' => 413 )
368 );
369 }
370
371 $quota = openstation_stored_files_user_quota_bytes( $user_id );
372 if ( $quota > 0 && ( openstation_stored_files_total_bytes( $user_id ) + $size ) > $quota ) {
373 return new WP_Error(
374 'openstation_stored_files_quota_exceeded',
375 __( 'Your desktop storage is full.', 'desktop-mode' ),
376 array( 'status' => 403 )
377 );
378 }
379
380 $dir = openstation_stored_files_ensure_dir( $user_id );
381 if ( is_wp_error( $dir ) ) {
382 return $dir;
383 }
384
385 require_once ABSPATH . 'wp-admin/includes/file.php';
386
387 /**
388 * Filters the `ext => mime` allowlist for desktop-storage
389 * uploads. Defaults to the user-scoped WordPress policy.
390 * Additions here genuinely widen the policy (the scoped
391 * `upload_mimes` hook below keeps core's re-check in
392 * `wp_check_filetype_and_ext()` in agreement).
393 *
394 * @param array<string,string> $mimes Allowed map.
395 * @param int $user_id Uploader.
396 */
397 $mimes = (array) apply_filters(
398 'openstation_stored_files_allowed_mimes',
399 get_allowed_mime_types( $user_id ),
400 $user_id
401 );
402
403 $disk_name = wp_generate_uuid4();
404 $scoped_mime = static function () use ( $mimes ) {
405 return $mimes;
406 };
407 $name_cb = static function ( $dir_unused, $name_unused, $ext_unused ) use ( $disk_name ) {
408 return $disk_name;
409 };
410 // Resolve the target paths BEFORE hooking `upload_dir` and close
411 // over plain strings — `openstation_stored_files_dir()` calls
412 // `wp_get_upload_dir()`, which applies the `upload_dir` filter,
413 // so calling it from inside the closure would recurse infinitely.
414 $target_base = openstation_stored_files_dir();
415 $target_dir = openstation_stored_files_dir( $user_id );
416 $redirect = static function ( $dirs ) use ( $user_id, $target_base, $target_dir ) {
417 $dirs['subdir'] = '/' . $user_id;
418 $dirs['path'] = $target_dir;
419 $dirs['url'] = $dirs['baseurl'] . '/desktop-mode-files/' . $user_id;
420 $dirs['basedir'] = $target_base;
421 $dirs['baseurl'] = $dirs['baseurl'] . '/desktop-mode-files';
422 return $dirs;
423 };
424
425 /**
426 * Filters the `wp_handle_upload()` overrides for desktop-storage
427 * uploads. Exists mainly so tests (and future resumable layers
428 * feeding pre-staged files) can switch `action` to the sideload
429 * variant — never remove `test_form => false`.
430 *
431 * @param array $overrides Overrides array.
432 * @param int $user_id Uploader.
433 */
434 $overrides = (array) apply_filters(
435 'openstation_stored_files_upload_overrides',
436 array(
437 'test_form' => false,
438 'mimes' => $mimes,
439 'unique_filename_callback' => $name_cb,
440 ),
441 $user_id
442 );
443
444 add_filter( 'upload_dir', $redirect );
445 add_filter( 'upload_mimes', $scoped_mime );
446 $result = wp_handle_upload( $file, $overrides );
447 remove_filter( 'upload_mimes', $scoped_mime );
448 remove_filter( 'upload_dir', $redirect );
449
450 if ( isset( $result['error'] ) ) {
451 return new WP_Error(
452 'openstation_stored_files_upload_failed',
453 (string) $result['error'],
454 array( 'status' => 400 )
455 );
456 }
457
458 $path = (string) $result['file'];
459 return array(
460 'path' => $path,
461 'disk_name' => $disk_name,
462 'display_name' => sanitize_file_name( $client_name ),
463 'size_bytes' => (int) @filesize( $path ),
464 'mime' => (string) $result['type'],
465 );
466 }
467
468 /**
469 * Register step: stored-file row + folder resolution + placement.
470 *
471 * @internal
472 *
473 * @param int $user_id Uploader.
474 * @param array $received Return value of the receive step.
475 * @param int $parent_id Base target folder (0 = root).
476 * @param string $relative_path Optional `a/b/c.ext` path; directory
477 * segments are resolved under `$parent_id`.
478 * @param array|null $coords `x`, `y` for the placement, or null
479 * to auto-place at the next free slot.
480 * @return array|WP_Error `{ file_id, placement_id }`.
481 */
482 function openstation_files_upload_register( $user_id, $received, $parent_id, $relative_path = '', $coords = null ) {
483 $parent_id = max( 0, (int) $parent_id );
484
485 if ( '' !== (string) $relative_path ) {
486 $resolved = openstation_files_resolve_relative_path( (int) $user_id, $parent_id, (string) $relative_path );
487 if ( is_wp_error( $resolved ) ) {
488 return $resolved;
489 }
490 $parent_id = $resolved;
491 }
492
493 $file_id = openstation_stored_files_create(
494 (int) $user_id,
495 array(
496 'display_name' => $received['display_name'],
497 'disk_name' => $received['disk_name'],
498 'size_bytes' => $received['size_bytes'],
499 'mime' => $received['mime'],
500 )
501 );
502 if ( is_wp_error( $file_id ) ) {
503 return $file_id;
504 }
505
506 if ( is_array( $coords ) && isset( $coords['x'], $coords['y'] ) ) {
507 $placement_id = openstation_files_place(
508 (int) $user_id,
509 $parent_id,
510 'upload',
511 (string) $file_id,
512 array(
513 'x' => (int) $coords['x'],
514 'y' => (int) $coords['y'],
515 )
516 );
517 } else {
518 // No coords sent (folder-tree members, batch files after
519 // the first) — the server picks the next free grid slot so
520 // tiles never stack at the origin.
521 $placement_id = openstation_files_place_at_next_free_slot(
522 (int) $user_id,
523 $parent_id,
524 'upload',
525 (string) $file_id
526 );
527 }
528 if ( is_wp_error( $placement_id ) ) {
529 // Roll back through the store primitive so the documented
530 // `openstation_stored_file_created` / `_deleted` action pair
531 // stays balanced for subscribers (and the bytes go with the
532 // row — the caller's outer cleanup guard becomes a no-op).
533 openstation_stored_files_delete( (int) $file_id );
534 return $placement_id;
535 }
536
537 /**
538 * Fires after an upload lands (bytes, row, and placement all
539 * exist).
540 *
541 * @param int $file_id Stored-file id.
542 * @param int $placement_id Placement id.
543 * @param int $user_id Uploader.
544 */
545 do_action( 'openstation_stored_file_uploaded', (int) $file_id, (int) $placement_id, (int) $user_id );
546
547 return array(
548 'file_id' => (int) $file_id,
549 'placement_id' => (int) $placement_id,
550 );
551 }
552
553 /**
554 * Resolve the DIRECTORY part of `a/b/c.ext` to a folder id under
555 * `$base_parent_id`, creating folder rows + placements mkdir-p
556 * style. Existing folders are reused when the acting user already
557 * has a live folder of that name in that parent (dedupe — parallel
558 * uploads of one tree share segments instead of racing).
559 *
560 * The final path segment is the FILE name and is ignored here.
561 *
562 * @param int $user_id Acting user.
563 * @param int $base_parent_id Folder to resolve under (0 = root).
564 * @param string $relative_path `a/b/c.ext` or `a/b/` (trailing
565 * slash = pure directory path, e.g.
566 * an empty folder from a drag).
567 * @return int|WP_Error Folder id to place the file in.
568 */
569 function openstation_files_resolve_relative_path( $user_id, $base_parent_id, $relative_path ) {
570 global $wpdb;
571 $user_id = (int) $user_id;
572 $parent_id = max( 0, (int) $base_parent_id );
573 $path = str_replace( '\\', '/', (string) $relative_path );
574
575 if ( false !== strpos( $path, "\0" ) ) {
576 return new WP_Error( 'openstation_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
577 }
578
579 $is_dir_path = '/' === substr( $path, -1 );
580 $segments = array_values( array_filter( explode( '/', $path ), 'strlen' ) );
581 if ( ! $is_dir_path ) {
582 array_pop( $segments ); // Last segment is the file name.
583 }
584 if ( empty( $segments ) ) {
585 return $parent_id;
586 }
587 if ( count( $segments ) > 32 ) {
588 return new WP_Error( 'openstation_stored_files_path_too_deep', __( 'That folder tree is nested too deeply.', 'desktop-mode' ), array( 'status' => 400 ) );
589 }
590
591 $tables = openstation_files_table_names();
592 foreach ( $segments as $segment ) {
593 if ( '.' === $segment || '..' === $segment ) {
594 return new WP_Error( 'openstation_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
595 }
596 $name = sanitize_file_name( wp_strip_all_tags( $segment ) );
597 if ( '' === $name ) {
598 return new WP_Error( 'openstation_stored_files_bad_path', __( 'Invalid path.', 'desktop-mode' ), array( 'status' => 400 ) );
599 }
600
601 // Dedupe: a live folder of this name, placed in this parent,
602 // owned by the acting user.
603 $existing = $wpdb->get_var(
604 $wpdb->prepare(
605 "SELECT f.id FROM {$tables['folders']} f
606 INNER JOIN {$tables['placements']} p
607 ON p.file_type = 'folder'
608 AND p.file_ref = CAST( f.id AS CHAR )
609 AND p.trashed_at_ms IS NULL
610 WHERE p.parent_id = %d
611 AND p.owner_id = %d
612 AND f.owner_id = %d
613 AND f.trashed_at_ms IS NULL
614 AND f.name = %s
615 LIMIT 1",
616 $parent_id,
617 $user_id,
618 $user_id,
619 $name
620 )
621 );
622 if ( $existing ) {
623 $parent_id = (int) $existing;
624 continue;
625 }
626
627 $folder_id = openstation_files_create_folder( $user_id, array( 'name' => $name ) );
628 if ( is_wp_error( $folder_id ) ) {
629 return $folder_id;
630 }
631 $placement = openstation_files_place( $user_id, $parent_id, 'folder', (string) $folder_id );
632 if ( is_wp_error( $placement ) ) {
633 return $placement;
634 }
635 $parent_id = (int) $folder_id;
636 }
637 return $parent_id;
638 }
639
640 /**
641 * Shell-config injection: what the client upload/download UX needs
642 * to know up front.
643 *
644 * @param array $config Shell config.
645 * @return array
646 */
647 function openstation_stored_files_inject_shell_config( $config ) {
648 $user_id = get_current_user_id();
649 $config['desktopStorage'] = array(
650 'canUpload' => $user_id > 0 && current_user_can( openstation_stored_files_upload_capability() ),
651 'maxBytes' => openstation_stored_files_max_upload_bytes( $user_id ),
652 'quotaBytes' => openstation_stored_files_user_quota_bytes( $user_id ),
653 'zipAvailable' => class_exists( 'ZipArchive' ),
654 );
655 return $config;
656 }
657 add_filter( 'openstation_shell_config', 'openstation_stored_files_inject_shell_config', 20 );
658