PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.8
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.8
1.1.10 1.1.9 1.1.8 1.1.7 1.1.6 1.1.5 1.1.4 1.1.3 1.1.2 1.1.1 1.1.0 1.0.1 1.0.0 0.9.8 0.9.7 0.9.6 0.9.4 0.9.5 0.9.3 0.9.2 0.9.1 0.9.0 0.8.9 0.8.8 0.8.7 All 34 releases
desktop-mode / includes / desktop-files / rest-uploads.php

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

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