PluginProbe
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin / 1.1.7
OpenStation: Desktop Windows, Dock & Virtual Desktops for WP Admin v1.1.7
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 1.1.7, at includes/desktop-files/downloads.php

460 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 * 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 if ( ! $zip->close() ) {
178 wp_delete_file( $tmp );
179 return new WP_Error( 'openstation_stored_files_zip_failed', __( 'Could not finish the archive (disk full?).', 'desktop-mode' ), array( 'status' => 500 ) );
180 }
181
182 /**
183 * Fires when a folder-zip download is about to be served.
184 *
185 * @param int $folder_id Folder id.
186 * @param int $user_id Downloader.
187 * @param int $count Number of files in the archive.
188 */
189 do_action( 'openstation_folder_zip_downloaded', $folder_id, $user_id, count( $manifest['entries'] ) );
190
191 $zip_name = sanitize_file_name( '' !== (string) $folder['name'] ? (string) $folder['name'] : 'folder' ) . '.zip';
192 return openstation_files_download_stream_response( $tmp, $zip_name, 'application/zip', true );
193 }
194
195 /**
196 * Recursive manifest collector. Walks the folder's placements
197 * (shared-namespace: every owner's rows), adds stored files the
198 * viewer can read, recurses into sub-folders, records empty
199 * directories, and enforces the caps.
200 *
201 * @internal
202 *
203 * @param int $folder_id Folder to walk.
204 * @param int $user_id Viewer.
205 * @param string $prefix Path prefix inside the zip ('' at root).
206 * @param array $manifest Accumulator (entries / empty_dirs / total_bytes).
207 * @param array $visited Folder ids already on the walk path (cycle guard).
208 * @param int $depth Current depth.
209 * @return array|WP_Error The updated manifest.
210 */
211 function openstation_files_collect_zip_entries( $folder_id, $user_id, $prefix, $manifest, $visited, $depth ) {
212 if ( $depth > 32 ) {
213 return $manifest; // Depth cap — quietly stop descending.
214 }
215
216 /**
217 * Filters the zip caps. `max_entries` bounds file count,
218 * `max_bytes` bounds the SUM of input sizes.
219 *
220 * @param array $caps `{ max_entries: int, max_bytes: int }`.
221 */
222 $caps = (array) apply_filters(
223 'openstation_stored_files_zip_caps',
224 array(
225 'max_entries' => 1000,
226 'max_bytes' => 500 * MB_IN_BYTES,
227 )
228 );
229
230 $rows = openstation_files_get_for_user_folder( $user_id, $folder_id );
231 $used_names = array(); // lowercase name => count, per directory.
232 $had_child = false;
233
234 foreach ( $rows as $row ) {
235 if ( 'folder' === $row['file_type'] ) {
236 $sub_id = (int) $row['file_ref'];
237 if ( $sub_id <= 0 || isset( $visited[ $sub_id ] ) ) {
238 continue;
239 }
240 $sub = openstation_files_get_folder( $sub_id );
241 if ( ! $sub ) {
242 continue;
243 }
244 $dir_name = openstation_files_zip_unique_name(
245 sanitize_file_name( '' !== (string) $sub['name'] ? (string) $sub['name'] : 'folder' ),
246 $used_names
247 );
248 $had_child = true;
249 $visited[ $sub_id ] = true;
250 $before = count( $manifest['entries'] ) + count( $manifest['empty_dirs'] );
251 $manifest = openstation_files_collect_zip_entries( $sub_id, $user_id, $prefix . $dir_name . '/', $manifest, $visited, $depth + 1 );
252 if ( is_wp_error( $manifest ) ) {
253 return $manifest;
254 }
255 if ( count( $manifest['entries'] ) + count( $manifest['empty_dirs'] ) === $before ) {
256 // Nothing inside — record the empty directory so the
257 // tree round-trips.
258 $manifest['empty_dirs'][] = $prefix . $dir_name . '/';
259 }
260 continue;
261 }
262 if ( 'upload' !== $row['file_type'] ) {
263 continue; // References are not bytes; skipped by design.
264 }
265 $file_id = (int) $row['file_ref'];
266 $file = openstation_stored_files_get( $file_id );
267 if ( ! $file || ! openstation_stored_file_user_can_read( $file_id, $user_id ) ) {
268 continue;
269 }
270 $path = openstation_stored_file_path( $file );
271 if ( ! $path || ! file_exists( $path ) ) {
272 continue;
273 }
274 $had_child = true;
275 $entry_name = openstation_files_zip_unique_name(
276 sanitize_file_name( '' !== $file['display_name'] ? $file['display_name'] : 'file' ),
277 $used_names
278 );
279
280 $manifest['total_bytes'] += (int) $file['size_bytes'];
281 if ( count( $manifest['entries'] ) + 1 > (int) $caps['max_entries'] ) {
282 return new WP_Error(
283 'openstation_stored_files_zip_too_big',
284 __( 'This folder has too many files to download as one archive.', 'desktop-mode' ),
285 array( 'status' => 400 )
286 );
287 }
288 if ( (int) $caps['max_bytes'] > 0 && $manifest['total_bytes'] > (int) $caps['max_bytes'] ) {
289 return new WP_Error(
290 'openstation_stored_files_zip_too_big',
291 __( 'This folder is too large to download as one archive.', 'desktop-mode' ),
292 array( 'status' => 400 )
293 );
294 }
295 $manifest['entries'][ $prefix . $entry_name ] = $path;
296 }
297
298 // An entirely empty folder at the walk root still yields a
299 // well-formed (empty) zip; sub-folder emptiness is recorded by
300 // the caller. Nothing to do here when $had_child is false.
301 unset( $had_child );
302
303 return $manifest;
304 }
305
306 /**
307 * Per-directory case-insensitive dedupe: `report.pdf`,
308 * `Report.pdf` → `report.pdf`, `Report (2).pdf` so extraction on
309 * case-folding filesystems (Windows, macOS) never collides.
310 *
311 * @internal
312 *
313 * @param string $name Sanitized candidate name.
314 * @param array $used_names By-ref lowercase tally for the directory.
315 * @return string
316 */
317 function openstation_files_zip_unique_name( $name, &$used_names ) {
318 $key = strtolower( $name );
319 if ( ! isset( $used_names[ $key ] ) ) {
320 $used_names[ $key ] = 1;
321 return $name;
322 }
323 ++$used_names[ $key ];
324 $n = $used_names[ $key ];
325 $dot = strrpos( $name, '.' );
326 if ( false === $dot || 0 === $dot ) {
327 return $name . " ($n)";
328 }
329 return substr( $name, 0, $dot ) . " ($n)" . substr( $name, $dot );
330 }
331
332 /**
333 * Build the marker response the `rest_pre_serve_request` filter
334 * streams. The marker payload never reaches the client — the
335 * filter takes over the output entirely.
336 *
337 * @internal
338 *
339 * @param string $path Absolute file path.
340 * @param string $name Download filename shown to the user.
341 * @param string $mime MIME type ('' = octet-stream).
342 * @param bool $delete_after Delete `$path` after streaming (zip temp).
343 * @return WP_REST_Response
344 */
345 function openstation_files_download_stream_response( $path, $name, $mime, $delete_after ) {
346 return new WP_REST_Response(
347 array(
348 '__openstation_stream' => array(
349 'path' => (string) $path,
350 'name' => (string) $name,
351 'mime' => (string) $mime,
352 'delete_after' => (bool) $delete_after,
353 ),
354 ),
355 200
356 );
357 }
358
359 /**
360 * `rest_pre_serve_request` short-circuit: stream the file the
361 * download callbacks resolved. Non-stream results (errors included)
362 * fall through to normal JSON serving.
363 *
364 * @param bool $served Whether the request is already served.
365 * @param WP_HTTP_Response $result Result to send.
366 * @param WP_REST_Request $request Request used.
367 * @return bool
368 */
369 function openstation_files_serve_download( $served, $result, $request ) {
370 if ( $served || ! $result instanceof WP_HTTP_Response ) {
371 return $served;
372 }
373 $route = (string) $request->get_route();
374 if ( ! preg_match( '#^/desktop-mode/v1/files/(uploads|folders)/\d+/download$#', $route ) ) {
375 return $served;
376 }
377 $data = $result->get_data();
378 if ( ! is_array( $data ) || empty( $data['__openstation_stream'] ) || 200 !== $result->get_status() ) {
379 return $served; // Error shapes serialize as normal JSON.
380 }
381 $stream = $data['__openstation_stream'];
382 $path = (string) $stream['path'];
383 if ( '' === $path || ! file_exists( $path ) || ! is_readable( $path ) ) {
384 return $served;
385 }
386
387 openstation_files_emit_download( $path, (string) $stream['name'], (string) $stream['mime'] );
388
389 if ( ! empty( $stream['delete_after'] ) ) {
390 wp_delete_file( $path );
391 }
392 return true;
393 }
394 add_filter( 'rest_pre_serve_request', 'openstation_files_serve_download', 10, 3 );
395
396 /**
397 * Send the headers and the bytes. Split out so PHPUnit can target
398 * the header/name logic without hijacking output.
399 *
400 * @internal
401 *
402 * @param string $path Absolute file path.
403 * @param string $name Download filename.
404 * @param string $mime MIME type.
405 */
406 function openstation_files_emit_download( $path, $name, $mime ) {
407 $size = (int) filesize( $path );
408
409 // Kill every output buffer + compression layer so
410 // Content-Length stays exact and readfile streams instead of
411 // ballooning through a buffer.
412 // phpcs:ignore Generic.CodeAnalysis.EmptyStatement
413 while ( ob_get_level() > 0 ) {
414 ob_end_clean();
415 }
416 if ( function_exists( 'apache_setenv' ) ) {
417 // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
418 @apache_setenv( 'no-gzip', '1' );
419 }
420 // phpcs:ignore WordPress.PHP.IniSet.Risky
421 @ini_set( 'zlib.output_compression', 'Off' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
422
423 nocache_headers();
424 header( 'X-Content-Type-Options: nosniff' );
425 header( 'Content-Type: ' . ( '' !== $mime ? $mime : 'application/octet-stream' ) );
426 header( 'Content-Length: ' . $size );
427 header( 'Accept-Ranges: none' );
428
429 // RFC 6266: ASCII fallback + RFC 5987 UTF-8 form. Always
430 // `attachment` — uploaded SVG/HTML must never render from this
431 // origin.
432 $ascii = preg_replace( '/[^\x20-\x7E]/', '_', $name );
433 $ascii = str_replace( array( '"', '\\' ), '_', (string) $ascii );
434 header(
435 'Content-Disposition: attachment; filename="' . $ascii . '"'
436 . "; filename*=UTF-8''" . rawurlencode( $name )
437 );
438
439 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile
440 readfile( $path );
441 }
442
443 /**
444 * Daily sweep of stale zip temp files (aborted downloads whose
445 * shutdown cleanup never ran).
446 */
447 function openstation_stored_files_sweep_zip_temps() {
448 $entries = glob( trailingslashit( get_temp_dir() ) . 'os-folder-zip*' );
449 foreach ( (array) $entries as $entry ) {
450 if ( ! is_file( $entry ) ) {
451 continue;
452 }
453 $mtime = (int) filemtime( $entry );
454 if ( $mtime > 0 && ( time() - $mtime ) > DAY_IN_SECONDS ) {
455 wp_delete_file( $entry );
456 }
457 }
458 }
459 add_action( 'desktop_mode_files_daily_prune', 'openstation_stored_files_sweep_zip_temps' );
460