PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 0.9.6
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v0.9.6
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 / downloads.php

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

475 lines 15.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Desktop Mode — authenticated downloads for stored files.
4 *
5 * Two routes:
6 *
7 * GET /desktop-mode/v1/files/uploads/(?P<id>\d+)/download
8 * Streams one stored file's bytes, unmodified.
9 * GET /desktop-mode/v1/files/folders/(?P<id>\d+)/download
10 * Builds an on-demand .zip of the folder's STORED-FILE
11 * contents (reference-type placements are skipped) and
12 * streams it.
13 *
14 * Auth: cookie + `_wpnonce` query parameter (the officially
15 * supported GET form — an `<a>` navigation can't set the
16 * `X-WP-Nonce` header). URLs are minted client-side at click time
17 * and never persisted. The route also reads an optional `token`
18 * param reserved for a future signed-link layer; it is currently
19 * ignored.
20 *
21 * Byte serving happens in a `rest_pre_serve_request` short-circuit
22 * — the REST server has already sent its JSON Content-Type header
23 * by dispatch time, and `header()` replacement inside the filter is
24 * the sanctioned way to take over the response (the same route
25 * still keeps `permission_callback`, error JSON, and logging).
26 *
27 * Not-found and no-access are both 404 — a download probe must not
28 * reveal that a file exists (the Drive behavior).
29 *
30 * @package WPDesktopMode
31 * @since 0.9.6
32 */
33
34 defined( 'ABSPATH' ) || exit;
35
36 /**
37 * Register the download routes.
38 *
39 * @since 0.9.6
40 */
41 function desktop_mode_files_register_download_rest_routes() {
42 $ns = 'desktop-mode/v1';
43 register_rest_route( $ns, '/files/uploads/(?P<id>\d+)/download', array(
44 'methods' => WP_REST_Server::READABLE,
45 'permission_callback' => 'desktop_mode_files_rest_permission',
46 'callback' => 'desktop_mode_files_rest_download_file',
47 ) );
48 register_rest_route( $ns, '/files/folders/(?P<id>\d+)/download', array(
49 'methods' => WP_REST_Server::READABLE,
50 'permission_callback' => 'desktop_mode_files_rest_permission',
51 'callback' => 'desktop_mode_files_rest_download_folder_zip',
52 ) );
53 }
54 add_action( 'rest_api_init', 'desktop_mode_files_register_download_rest_routes' );
55
56 /**
57 * The masked not-found error shared by every failure path that
58 * must not leak existence.
59 *
60 * @since 0.9.6
61 *
62 * @return WP_Error
63 */
64 function desktop_mode_files_download_not_found() {
65 return new WP_Error(
66 'desktop_mode_files_not_found',
67 __( 'File not found.', 'desktop-mode' ),
68 array( 'status' => 404 )
69 );
70 }
71
72 /**
73 * GET /files/uploads/<id>/download
74 *
75 * @since 0.9.6
76 *
77 * @param WP_REST_Request $req Request.
78 * @return WP_REST_Response|WP_Error
79 */
80 function desktop_mode_files_rest_download_file( WP_REST_Request $req ) {
81 $file_id = (int) $req['id'];
82 $user_id = get_current_user_id();
83 $row = desktop_mode_stored_files_get( $file_id );
84 if ( ! $row || ! desktop_mode_stored_file_user_can_read( $file_id, $user_id ) ) {
85 return desktop_mode_files_download_not_found();
86 }
87 $path = desktop_mode_stored_file_path( $row );
88 if ( ! $path || ! file_exists( $path ) ) {
89 return desktop_mode_files_download_not_found();
90 }
91
92 /**
93 * Fires when a stored-file download is about to be served.
94 *
95 * @since 0.9.6
96 *
97 * @param int $file_id Stored-file id.
98 * @param int $user_id Downloader.
99 */
100 do_action( 'desktop_mode_stored_file_downloaded', $file_id, $user_id );
101
102 return desktop_mode_files_download_stream_response(
103 $path,
104 $row['display_name'],
105 $row['mime'],
106 false
107 );
108 }
109
110 /**
111 * GET /files/folders/<id>/download — zip the folder's stored files.
112 *
113 * @since 0.9.6
114 *
115 * @param WP_REST_Request $req Request.
116 * @return WP_REST_Response|WP_Error
117 */
118 function desktop_mode_files_rest_download_folder_zip( WP_REST_Request $req ) {
119 $folder_id = (int) $req['id'];
120 $user_id = get_current_user_id();
121 $folder = desktop_mode_files_get_folder( $folder_id );
122 if ( ! $folder ) {
123 return desktop_mode_files_download_not_found();
124 }
125 $is_owner = (int) $folder['owner_id'] === $user_id;
126 if ( ! $is_owner ) {
127 $cap = function_exists( 'desktop_mode_folder_share_user_capability' )
128 ? desktop_mode_folder_share_user_capability( $folder_id, $user_id )
129 : 'none';
130 if ( 'none' === $cap ) {
131 return desktop_mode_files_download_not_found();
132 }
133 }
134 if ( ! class_exists( 'ZipArchive' ) ) {
135 return new WP_Error(
136 'desktop_mode_stored_files_no_zip',
137 __( 'Folder download requires the PHP zip extension.', 'desktop-mode' ),
138 array( 'status' => 501 )
139 );
140 }
141
142 $manifest = array(
143 'entries' => array(), // path-in-zip => absolute path.
144 'empty_dirs' => array(), // path-in-zip (with trailing /).
145 'total_bytes' => 0,
146 );
147 $result = desktop_mode_files_collect_zip_entries( $folder_id, $user_id, '', $manifest, array( $folder_id => true ), 0 );
148 if ( is_wp_error( $result ) ) {
149 return $result;
150 }
151 $manifest = $result;
152
153 require_once ABSPATH . 'wp-admin/includes/file.php';
154 $tmp = wp_tempnam( 'desktop-mode-folder-zip' );
155 if ( ! $tmp ) {
156 return new WP_Error( 'desktop_mode_stored_files_zip_failed', __( 'Could not create the archive.', 'desktop-mode' ), array( 'status' => 500 ) );
157 }
158 // Belt and braces for aborted connections — the normal path
159 // deletes right after streaming.
160 register_shutdown_function( 'wp_delete_file', $tmp );
161
162 $zip = new ZipArchive();
163 if ( true !== $zip->open( $tmp, ZipArchive::OVERWRITE ) ) {
164 wp_delete_file( $tmp );
165 return new WP_Error( 'desktop_mode_stored_files_zip_failed', __( 'Could not create the archive.', 'desktop-mode' ), array( 'status' => 500 ) );
166 }
167 foreach ( $manifest['empty_dirs'] as $dir_entry ) {
168 $zip->addEmptyDir( $dir_entry );
169 }
170 foreach ( $manifest['entries'] as $entry_name => $abs_path ) {
171 $zip->addFile( $abs_path, $entry_name );
172 if ( method_exists( $zip, 'setCompressionName' ) ) {
173 // Media / archives are already compressed; STORE saves
174 // CPU for nothing lost. Cheap heuristic on the entry name.
175 if ( preg_match( '/\.(zip|gz|bz2|7z|rar|jpe?g|png|gif|webp|avif|mp3|mp4|m4a|mov|webm|ogg|pdf)$/i', $entry_name ) ) {
176 $zip->setCompressionName( $entry_name, ZipArchive::CM_STORE );
177 }
178 }
179 }
180 if ( ! $zip->close() ) {
181 wp_delete_file( $tmp );
182 return new WP_Error( 'desktop_mode_stored_files_zip_failed', __( 'Could not finish the archive (disk full?).', 'desktop-mode' ), array( 'status' => 500 ) );
183 }
184
185 /**
186 * Fires when a folder-zip download is about to be served.
187 *
188 * @since 0.9.6
189 *
190 * @param int $folder_id Folder id.
191 * @param int $user_id Downloader.
192 * @param int $count Number of files in the archive.
193 */
194 do_action( 'desktop_mode_folder_zip_downloaded', $folder_id, $user_id, count( $manifest['entries'] ) );
195
196 $zip_name = sanitize_file_name( '' !== (string) $folder['name'] ? (string) $folder['name'] : 'folder' ) . '.zip';
197 return desktop_mode_files_download_stream_response( $tmp, $zip_name, 'application/zip', true );
198 }
199
200 /**
201 * Recursive manifest collector. Walks the folder's placements
202 * (shared-namespace: every owner's rows), adds stored files the
203 * viewer can read, recurses into sub-folders, records empty
204 * directories, and enforces the caps.
205 *
206 * @since 0.9.6
207 * @internal
208 *
209 * @param int $folder_id Folder to walk.
210 * @param int $user_id Viewer.
211 * @param string $prefix Path prefix inside the zip ('' at root).
212 * @param array $manifest Accumulator (entries / empty_dirs / total_bytes).
213 * @param array $visited Folder ids already on the walk path (cycle guard).
214 * @param int $depth Current depth.
215 * @return array|WP_Error The updated manifest.
216 */
217 function desktop_mode_files_collect_zip_entries( $folder_id, $user_id, $prefix, $manifest, $visited, $depth ) {
218 if ( $depth > 32 ) {
219 return $manifest; // Depth cap — quietly stop descending.
220 }
221
222 /**
223 * Filters the zip caps. `max_entries` bounds file count,
224 * `max_bytes` bounds the SUM of input sizes.
225 *
226 * @since 0.9.6
227 *
228 * @param array $caps `{ max_entries: int, max_bytes: int }`.
229 */
230 $caps = (array) apply_filters(
231 'desktop_mode_stored_files_zip_caps',
232 array(
233 'max_entries' => 1000,
234 'max_bytes' => 500 * MB_IN_BYTES,
235 )
236 );
237
238 $rows = desktop_mode_files_get_for_user_folder( $user_id, $folder_id );
239 $used_names = array(); // lowercase name => count, per directory.
240 $had_child = false;
241
242 foreach ( $rows as $row ) {
243 if ( 'folder' === $row['file_type'] ) {
244 $sub_id = (int) $row['file_ref'];
245 if ( $sub_id <= 0 || isset( $visited[ $sub_id ] ) ) {
246 continue;
247 }
248 $sub = desktop_mode_files_get_folder( $sub_id );
249 if ( ! $sub ) {
250 continue;
251 }
252 $dir_name = desktop_mode_files_zip_unique_name(
253 sanitize_file_name( '' !== (string) $sub['name'] ? (string) $sub['name'] : 'folder' ),
254 $used_names
255 );
256 $had_child = true;
257 $visited[ $sub_id ] = true;
258 $before = count( $manifest['entries'] ) + count( $manifest['empty_dirs'] );
259 $manifest = desktop_mode_files_collect_zip_entries( $sub_id, $user_id, $prefix . $dir_name . '/', $manifest, $visited, $depth + 1 );
260 if ( is_wp_error( $manifest ) ) {
261 return $manifest;
262 }
263 if ( count( $manifest['entries'] ) + count( $manifest['empty_dirs'] ) === $before ) {
264 // Nothing inside — record the empty directory so the
265 // tree round-trips.
266 $manifest['empty_dirs'][] = $prefix . $dir_name . '/';
267 }
268 continue;
269 }
270 if ( 'upload' !== $row['file_type'] ) {
271 continue; // References are not bytes; skipped by design.
272 }
273 $file_id = (int) $row['file_ref'];
274 $file = desktop_mode_stored_files_get( $file_id );
275 if ( ! $file || ! desktop_mode_stored_file_user_can_read( $file_id, $user_id ) ) {
276 continue;
277 }
278 $path = desktop_mode_stored_file_path( $file );
279 if ( ! $path || ! file_exists( $path ) ) {
280 continue;
281 }
282 $had_child = true;
283 $entry_name = desktop_mode_files_zip_unique_name(
284 sanitize_file_name( '' !== $file['display_name'] ? $file['display_name'] : 'file' ),
285 $used_names
286 );
287
288 $manifest['total_bytes'] += (int) $file['size_bytes'];
289 if ( count( $manifest['entries'] ) + 1 > (int) $caps['max_entries'] ) {
290 return new WP_Error(
291 'desktop_mode_stored_files_zip_too_big',
292 __( 'This folder has too many files to download as one archive.', 'desktop-mode' ),
293 array( 'status' => 400 )
294 );
295 }
296 if ( (int) $caps['max_bytes'] > 0 && $manifest['total_bytes'] > (int) $caps['max_bytes'] ) {
297 return new WP_Error(
298 'desktop_mode_stored_files_zip_too_big',
299 __( 'This folder is too large to download as one archive.', 'desktop-mode' ),
300 array( 'status' => 400 )
301 );
302 }
303 $manifest['entries'][ $prefix . $entry_name ] = $path;
304 }
305
306 // An entirely empty folder at the walk root still yields a
307 // well-formed (empty) zip; sub-folder emptiness is recorded by
308 // the caller. Nothing to do here when $had_child is false.
309 unset( $had_child );
310
311 return $manifest;
312 }
313
314 /**
315 * Per-directory case-insensitive dedupe: `report.pdf`,
316 * `Report.pdf` → `report.pdf`, `Report (2).pdf` so extraction on
317 * case-folding filesystems (Windows, macOS) never collides.
318 *
319 * @since 0.9.6
320 * @internal
321 *
322 * @param string $name Sanitized candidate name.
323 * @param array $used_names By-ref lowercase tally for the directory.
324 * @return string
325 */
326 function desktop_mode_files_zip_unique_name( $name, &$used_names ) {
327 $key = strtolower( $name );
328 if ( ! isset( $used_names[ $key ] ) ) {
329 $used_names[ $key ] = 1;
330 return $name;
331 }
332 $used_names[ $key ]++;
333 $n = $used_names[ $key ];
334 $dot = strrpos( $name, '.' );
335 if ( false === $dot || 0 === $dot ) {
336 return $name . " ($n)";
337 }
338 return substr( $name, 0, $dot ) . " ($n)" . substr( $name, $dot );
339 }
340
341 /**
342 * Build the marker response the `rest_pre_serve_request` filter
343 * streams. The marker payload never reaches the client — the
344 * filter takes over the output entirely.
345 *
346 * @since 0.9.6
347 * @internal
348 *
349 * @param string $path Absolute file path.
350 * @param string $name Download filename shown to the user.
351 * @param string $mime MIME type ('' = octet-stream).
352 * @param bool $delete_after Delete `$path` after streaming (zip temp).
353 * @return WP_REST_Response
354 */
355 function desktop_mode_files_download_stream_response( $path, $name, $mime, $delete_after ) {
356 return new WP_REST_Response(
357 array(
358 '__desktop_mode_stream' => array(
359 'path' => (string) $path,
360 'name' => (string) $name,
361 'mime' => (string) $mime,
362 'delete_after' => (bool) $delete_after,
363 ),
364 ),
365 200
366 );
367 }
368
369 /**
370 * `rest_pre_serve_request` short-circuit: stream the file the
371 * download callbacks resolved. Non-stream results (errors included)
372 * fall through to normal JSON serving.
373 *
374 * @since 0.9.6
375 *
376 * @param bool $served Whether the request is already served.
377 * @param WP_HTTP_Response $result Result to send.
378 * @param WP_REST_Request $request Request used.
379 * @return bool
380 */
381 function desktop_mode_files_serve_download( $served, $result, $request ) {
382 if ( $served || ! $result instanceof WP_HTTP_Response ) {
383 return $served;
384 }
385 $route = (string) $request->get_route();
386 if ( ! preg_match( '#^/desktop-mode/v1/files/(uploads|folders)/\d+/download$#', $route ) ) {
387 return $served;
388 }
389 $data = $result->get_data();
390 if ( ! is_array( $data ) || empty( $data['__desktop_mode_stream'] ) || 200 !== $result->get_status() ) {
391 return $served; // Error shapes serialize as normal JSON.
392 }
393 $stream = $data['__desktop_mode_stream'];
394 $path = (string) $stream['path'];
395 if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) {
396 return $served;
397 }
398
399 desktop_mode_files_emit_download( $path, (string) $stream['name'], (string) $stream['mime'] );
400
401 if ( ! empty( $stream['delete_after'] ) ) {
402 wp_delete_file( $path );
403 }
404 return true;
405 }
406 add_filter( 'rest_pre_serve_request', 'desktop_mode_files_serve_download', 10, 3 );
407
408 /**
409 * Send the headers and the bytes. Split out so PHPUnit can target
410 * the header/name logic without hijacking output.
411 *
412 * @since 0.9.6
413 * @internal
414 *
415 * @param string $path Absolute file path.
416 * @param string $name Download filename.
417 * @param string $mime MIME type.
418 */
419 function desktop_mode_files_emit_download( $path, $name, $mime ) {
420 $size = (int) filesize( $path );
421
422 // Kill every output buffer + compression layer so
423 // Content-Length stays exact and readfile streams instead of
424 // ballooning through a buffer.
425 // phpcs:ignore Generic.CodeAnalysis.EmptyStatement
426 while ( ob_get_level() > 0 ) {
427 ob_end_clean();
428 }
429 if ( function_exists( 'apache_setenv' ) ) {
430 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
431 @apache_setenv( 'no-gzip', '1' );
432 }
433 // phpcs:ignore WordPress.PHP.IniSet.Risky
434 @ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
435
436 nocache_headers();
437 header( 'X-Content-Type-Options: nosniff' );
438 header( 'Content-Type: ' . ( '' !== $mime ? $mime : 'application/octet-stream' ) );
439 header( 'Content-Length: ' . $size );
440 header( 'Accept-Ranges: none' );
441
442 // RFC 6266: ASCII fallback + RFC 5987 UTF-8 form. Always
443 // `attachment` — uploaded SVG/HTML must never render from this
444 // origin.
445 $ascii = preg_replace( '/[^\x20-\x7E]/', '_', $name );
446 $ascii = str_replace( array( '"', '\\' ), '_', (string) $ascii );
447 header(
448 'Content-Disposition: attachment; filename="' . $ascii . '"'
449 . "; filename*=UTF-8''" . rawurlencode( $name )
450 );
451
452 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
453 readfile( $path );
454 }
455
456 /**
457 * Daily sweep of stale zip temp files (aborted downloads whose
458 * shutdown cleanup never ran).
459 *
460 * @since 0.9.6
461 */
462 function desktop_mode_stored_files_sweep_zip_temps() {
463 $entries = glob( trailingslashit( get_temp_dir() ) . 'desktop-mode-folder-zip*' );
464 foreach ( (array) $entries as $entry ) {
465 if ( ! is_file( $entry ) ) {
466 continue;
467 }
468 $mtime = (int) filemtime( $entry );
469 if ( $mtime > 0 && ( time() - $mtime ) > DAY_IN_SECONDS ) {
470 wp_delete_file( $entry );
471 }
472 }
473 }
474 add_action( 'desktop_mode_files_daily_prune', 'desktop_mode_stored_files_sweep_zip_temps' );
475