Add HTTP Range/partial-content support to attachment downloads (#99)
download_attachment.php always streamed the entire file regardless of any Range request header, and never advertised Accept-Ranges. Large video/PDF attachments couldn't be scrubbed in-browser, and an interrupted download had to restart from byte 0. Now parses a single-range "bytes=start-end" (including open-ended and suffix forms) request header and responds with 206 Partial Content and a Content-Range header, seeking the file handle to the requested offset; out-of-range requests get 416 with Content-Range: bytes */<size>. Verified against a real file served over a local PHP dev server with curl for exact-range, open-ended, suffix, no-Range, and out-of-bounds cases, confirming byte-identical output for each. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015nCxwFFsy8ouMWzn56rPVP
This commit is contained in:
@@ -102,12 +102,50 @@ try {
|
||||
// Sanitize filename for Content-Disposition
|
||||
$safeFilename = preg_replace('/[^\w\s\-\.]/', '_', $attachment['original_filename']);
|
||||
|
||||
$fileSize = filesize($filePath);
|
||||
|
||||
// Parse a single-range "Range: bytes=start-end" request header (RFC 7233).
|
||||
// Multi-range requests aren't supported; they fall through to a full 200 response.
|
||||
$rangeStart = 0;
|
||||
$rangeEnd = $fileSize - 1;
|
||||
$isRangeRequest = false;
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE']) && preg_match('/^bytes=(\d*)-(\d*)$/', trim($_SERVER['HTTP_RANGE']), $m)) {
|
||||
if ($m[1] === '' && $m[2] === '') {
|
||||
// Malformed ("bytes=-") — ignore and serve the full file.
|
||||
} elseif ($m[1] === '') {
|
||||
// Suffix range: last N bytes
|
||||
$suffixLength = (int)$m[2];
|
||||
$rangeStart = max(0, $fileSize - $suffixLength);
|
||||
$rangeEnd = $fileSize - 1;
|
||||
$isRangeRequest = true;
|
||||
} else {
|
||||
$rangeStart = (int)$m[1];
|
||||
$rangeEnd = ($m[2] === '') ? $fileSize - 1 : min((int)$m[2], $fileSize - 1);
|
||||
$isRangeRequest = true;
|
||||
}
|
||||
|
||||
if ($isRangeRequest && ($rangeStart > $rangeEnd || $rangeStart >= $fileSize)) {
|
||||
http_response_code(416);
|
||||
header('Content-Range: bytes */' . $fileSize);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
|
||||
$rangeLength = $rangeEnd - $rangeStart + 1;
|
||||
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Type: ' . $attachment['mime_type']);
|
||||
header('Content-Disposition: ' . $disposition . '; filename="' . $safeFilename . '"');
|
||||
header('Content-Length: ' . $attachment['file_size']);
|
||||
header('Cache-Control: private, max-age=3600');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
|
||||
if ($isRangeRequest) {
|
||||
http_response_code(206);
|
||||
header('Content-Range: bytes ' . $rangeStart . '-' . $rangeEnd . '/' . $fileSize);
|
||||
}
|
||||
header('Content-Length: ' . $rangeLength);
|
||||
|
||||
// Prevent PHP from timing out on large files
|
||||
set_time_limit(0);
|
||||
|
||||
@@ -125,9 +163,18 @@ try {
|
||||
exit;
|
||||
}
|
||||
|
||||
while (!feof($handle)) {
|
||||
echo fread($handle, 8192);
|
||||
fseek($handle, $rangeStart);
|
||||
$remaining = $rangeLength;
|
||||
$chunkSize = 8192;
|
||||
while ($remaining > 0 && !feof($handle)) {
|
||||
$read = ($remaining < $chunkSize) ? $remaining : $chunkSize;
|
||||
$data = fread($handle, $read);
|
||||
if ($data === false) {
|
||||
break;
|
||||
}
|
||||
echo $data;
|
||||
flush();
|
||||
$remaining -= strlen($data);
|
||||
}
|
||||
|
||||
fclose($handle);
|
||||
|
||||
Reference in New Issue
Block a user