PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.13
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.13
1.1.13 1.1.12 1.1.11 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 All 37 releases
desktop-mode / includes / desktop-files / downloads.php

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

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