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 / downloads.php

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

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