From f9e411dda80a39143502fc102604cc24a5f99c06 Mon Sep 17 00:00:00 2001 From: unknown Date: Thu, 27 Aug 2026 03:49:56 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E4=BA=86=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Content/Download.php | 138 +++++++++++++++++++++++---------------- 1 file changed, 82 insertions(+), 56 deletions(-) diff --git a/src/Content/Download.php b/src/Content/Download.php index 2f99e604..36d09d57 100644 --- a/src/Content/Download.php +++ b/src/Content/Download.php @@ -329,18 +329,21 @@ class Download { $idx = (int) $row['source_index']; if (empty($meta['downloads'][$idx]['url'])) $this->fail_410(); - // HEAD probes validate the link but must never consume it. - $is_head = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD'; + // Probes (HEAD requests and Speculative-Loading prefetches) validate + // availability but must never consume a use nor waste bandwidth. + $is_probe = $this->is_probe_request(); $target_url = esc_url_raw((string) $meta['downloads'][$idx]['url']); // Local attachment / uploads file: stream without artificial throttling. $local_path = $this->resolve_local_download_path($target_url); if ($local_path !== '' && is_readable($local_path)) { - $delivered = $this->stream_local_file($local_path); - // Count only fully delivered bodies; aborted transfers stay reusable. - if ($delivered && !$is_head) { - $this->consume_token($row, $token); + $this->send_download_headers($local_path); + if (!$is_probe) { + // Count only fully delivered bodies; aborted transfers stay reusable. + if ($this->stream_body($local_path)) { + $this->consume_token($row, $token); + } } exit; } @@ -348,13 +351,84 @@ class Download { nocache_headers(); wp_redirect($target_url, 302); // Remote hosts give no completion signal, so the redirect hand-off - // counts as delivery; HEAD probes still never consume. - if (!$is_head) { + // counts as delivery; probes still never consume. + if (!$is_probe) { $this->consume_token($row, $token); } exit; } + /** + * Requests that inspect the link instead of taking the download: plain + * HEAD plus browser speculative prefetches (Sec-Purpose: prefetch). + */ + private function is_probe_request(): bool { + if (strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')) === 'HEAD') { + return true; + } + $purpose = strtolower((string) ($_SERVER['HTTP_SEC_PURPOSE'] ?? ($_SERVER['HTTP_PURPOSE'] ?? ''))); + return $purpose !== '' && str_contains($purpose, 'prefetch'); + } + + /** + * Emit the attachment headers for a local download. + */ + private function send_download_headers(string $path): void { + $filename = basename($path); + $size = filesize($path); + if ($size === false) { + $this->fail_410(); + } + + nocache_headers(); + header('Content-Description: File Transfer'); + header('Content-Type: application/octet-stream'); + header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); + header('Content-Transfer-Encoding: binary'); + header('Content-Length: ' . (string) $size); + } + + /** + * Stream the local file body after its headers. Returns true only when + * every byte was flushed to the client. + */ + private function stream_body(string $path): bool { + $size = filesize($path); + if ($size === false) { + $this->fail_410(); + } + + $chunk = 1024 * 64; // 64KB + + $fh = fopen($path, 'rb'); + if (!$fh) { + $this->fail_410(); + } + // Disable output compression so Content-Length remains accurate. + if (function_exists('apache_setenv')) { + @apache_setenv('no-gzip', '1'); + } + @ini_set('zlib.output_compression', 'Off'); + while (!feof($fh)) { + if (connection_aborted()) { + fclose($fh); + return false; + } + $buf = fread($fh, $chunk); + if ($buf === false) { + fclose($fh); + return false; + } + echo $buf; + if (function_exists('flush')) { + flush(); + } + } + fclose($fh); + + return !connection_aborted(); + } + /** * Map a site-local URL to a filesystem path under uploads (or ABSPATH). */ @@ -388,54 +462,6 @@ class Download { return ''; } - /** - * Stream a local file. Returns true only when the body was sent in full. - */ - private function stream_local_file(string $path): bool { - $filename = basename($path); - $size = filesize($path); - if ($size === false) { - $this->fail_410(); - } - - nocache_headers(); - header('Content-Description: File Transfer'); - header('Content-Type: application/octet-stream'); - header('Content-Disposition: attachment; filename="' . rawurlencode($filename) . '"'); - header('Content-Transfer-Encoding: binary'); - header('Content-Length: ' . (string) $size); - - $chunk = 1024 * 64; // 64KB - - $fh = fopen($path, 'rb'); - if (!$fh) { - $this->fail_410(); - } - // Disable output compression so Content-Length remains accurate. - if (function_exists('apache_setenv')) { - @apache_setenv('no-gzip', '1'); - } - @ini_set('zlib.output_compression', 'Off'); - while (!feof($fh)) { - if (connection_aborted()) { - fclose($fh); - return false; - } - $buf = fread($fh, $chunk); - if ($buf === false) { - fclose($fh); - return false; - } - echo $buf; - if (function_exists('flush')) { - flush(); - } - } - fclose($fh); - - return !connection_aborted(); - } - /** * Count a delivered download. The guard caps concurrent finishers at * max_uses instead of overshooting the counter.