I know this is an old post, but I ended up needing to stream a video in Laravel from S3 to a player that required HTTP_RANGE support. I put this together (after reading many threads). It should support all disks() you define in Laravel.
I used the class below, placed at App/Http/Responses. To use this class, create a method that does this (this would be the content of your getFile method):
$filestream = new \App\Http\Responses\S3FileStream('file_path_and_name_within_bucket', 'disk_bucket_name');
return $filestream->output();
I just pointed my video player's src at a route for that method and success!
S3FileStream.php:
<?php
namespace Http\Responses;
use Illuminate\Http\Request;
use Storage;
class S3FileStream
{
/**
* @var \League\Flysystem\AwsS3v3\AwsS3Adapter
*/
private $adapter;
/**
* @var \Aws\S3\S3Client
*/
private $client;
/**
* @var file end byte
*/
private $end;
/**
* @var string
*/
private $filePath;
/**
* @var bool storing if request is a range (or a full file)
*/
private $isRange = false;
/**
* @var length of bytes requested
*/
private $length;
/**
* @var
*/
private $return_headers = [];
/**
* @var file size
*/
private $size;
/**
* @var start byte
*/
private $start;
/**
* S3FileStream constructor.
* @param string $filePath
* @param string $adapter
*/
public function __construct(string $filePath, string $adapter = 's3')
{
$this->filePath = $filePath;
$this->filesystem = Storage::disk($adapter)->getDriver();
$this->adapter = Storage::disk($adapter)->getAdapter();
$this->client = $this->adapter->getClient();
}
/**
* Output file to client.
*/
public function output()
{
return $this->setHeaders()->stream();
}
/**
* Output headers to client.
* @return $this
*/
protected function setHeaders()
{
$object = $this->client->headObject([
'Bucket' => $this->adapter->getBucket(),
'Key' => $this->filePath,
]);
$this->start = 0;
$this->size = $object['ContentLength'];
$this->end = $this->size - 1;
//Set headers
$this->return_headers = [];
$this->return_headers['Last-Modified'] = $object['LastModified'];
$this->return_headers['Accept-Ranges'] = 'bytes';
$this->return_headers['Content-Type'] = $object['ContentType'];
$this->return_headers['Content-Disposition'] = 'inline; filename=' . basename($this->filePath);
if (!is_null(request()->server('HTTP_RANGE'))) {
$c_start = $this->start;
$c_end = $this->end;
[$_, $range] = explode('=', request()->server('HTTP_RANGE'), 2);
if (strpos($range, ',') !== false) {
headers('Content-Range: bytes ' . $this->start . '-' . $this->end . '/' . $this->size);
return response('416 Requested Range Not Satisfiable', 416);
}
if ($range == '-') {
$c_start = $this->size - substr($range, 1);
} else {
$range = explode('-', $range);
$c_start = $range[0];
$c_end = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $c_end;
}
$c_end = ($c_end > $this->end) ? $this->end : $c_end;
if ($c_start > $c_end || $c_start > $this->size - 1 || $c_end >= $this->size) {
headers('Content-Range: bytes ' . $this->start . '-' . $this->end . '/' . $this->size);
return response('416 Requested Range Not Satisfiable', 416);
}
$this->start = $c_start;
$this->end = $c_end;
$this->length = $this->end - $this->start + 1;
$this->return_headers['Content-Length'] = $this->length;
$this->return_headers['Content-Range'] = 'bytes ' . $this->start . '-' . $this->end . '/' . $this->size;
$this->isRange = true;
} else {
$this->length = $this->size;
$this->return_headers['Content-Length'] = $this->length;
unset($this->return_headers['Content-Range']);
$this->isRange = false;
}
return $this;
}
/**
* Stream file to client.
* @throws \Exception
*/
protected function stream()
{
$this->client->registerStreamWrapper();
// Create a stream context to allow seeking
$context = stream_context_create([
's3' => [
'seekable' => true,
],
]);
// Open a stream in read-only mode
if (!($stream = fopen("s3://{$this->adapter->getBucket()}/{$this->filePath}", 'rb', false, $context))) {
throw new \Exception('Could not open stream for reading export [' . $this->filePath . ']');
}
if (isset($this->start)) {
fseek($stream, $this->start, SEEK_SET);
}
$remaining_bytes = $this->length ?? $this->size;
$chunk_size = 1024;
$video = response()->stream(
function () use ($stream, $remaining_bytes, $chunk_size) {
while (!feof($stream) && $remaining_bytes > 0) {
echo fread($stream, $chunk_size);
$remaining_bytes -= $chunk_size;
flush();
}
fclose($stream);
},
($this->isRange ? 206 : 200),
$this->return_headers
);
return $video;
}
}
If you're using a more modern version of Laravel (8+), I have a more advanced version of this class which leverages the Str and Arr helpers and provides better naming handling and support for both streaming and downloading files of any type
S3FileStream.php (Laravel 8+):
<?php
namespace Http\Responses;
use Exception;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Http\Response;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class S3FileStream
{
/**
* @var \League\Flysystem\AwsS3v3\AwsS3Adapter
*/
private $adapter;
/**
* Name of adapter.
*
* @var string
*/
private $adapterName;
/**
* Storage disk.
*
* @var FilesystemAdapter
*/
private $disk;
/**
* @var int file end byte
*/
private $end;
/**
* @var string
*/
private $filePath;
/**
* Human-known filename.
*
* @var string|null
*/
private $humanName;
/**
* @var bool storing if request is a range (or a full file)
*/
private $isRange = false;
/**
* @var int|null length of bytes requested
*/
private $length = null;
/**
* @var array
*/
private $returnHeaders = [];
/**
* @var int file size
*/
private $size;
/**
* @var int start byte
*/
private $start;
/**
* S3FileStream constructor.
* @param string $filePath
* @param string $adapter
* @param string $humanName
*/
public function __construct(string $filePath, string $adapter = 's3', ?string $humanName = null)
{
$this->filePath = $filePath;
$this->adapterName = $adapter;
$this->disk = Storage::disk($this->adapterName);
$this->adapter = $this->disk->getAdapter();
$this->humanName = $humanName;
//Set to zero until setHeadersAndStream is called
$this->start = 0;
$this->size = 0;
$this->end = 0;
}
/**
* Get the filename extension (with a leading .).
*
* @param string $filename
* @return string
*/
private static function getExtension(string $filename): string
{
$filenameArray = explode('.', $filename);
return Str::start(Arr::last($filenameArray), '.');
}
/**
* Sanitize a filename to be sure it will cause no errors on download.
*
* @param string $str
* @param bool $trimSpaces (default false)
* @return string (sanitized)
*/
private static function sanitizeFilename(string $str, bool $trimSpaces = false): string
{
$str = strip_tags($str);
$str = str_replace('/', '-', $str);
$str = str_replace('–', '_', $str); //en dash
$str = str_replace(':', ' - ', $str);
$str = str_replace('&', ' and ', $str);
$str = preg_replace('/[\r\n\t ]+/', ' ', $str);
$str = preg_replace('/[\"\*\/\:\<\>\?\'\|]+/', ' ', $str);
$str = html_entity_decode($str, ENT_QUOTES, 'utf-8');
$str = htmlentities($str, ENT_QUOTES, 'utf-8');
$str = preg_replace('/(&)([a-z])([a-z]+;)/i', '$2', $str);
if ($trimSpaces) {
$str = str_replace(' ', '_', $str);
$str = rawurlencode($str);
} else {
$str = rawurlencode($str);
$str = str_replace('%20', ' ', $str);
}
$str = str_replace('%', '-', $str);
//Truncate at 255 length
if (strlen($str) >= 255) {
$extension = static::getExtension($str);
$str = substr(Str::before($str, $extension), 0, 254 - strlen($extension)) . $extension;
}
return $str;
}
/**
* Output file to client as a file download.
* @return Response|StreamedResponse
*/
public function download()
{
return $this->setHeadersAndStream('attachment');
}
/**
* Output file to client as an inline video stream.
* @return Response|StreamedResponse
*/
public function output()
{
return $this->setHeadersAndStream('inline');
}
/**
* Output headers to client.
* @param string $responseMode
* @return Response|StreamedResponse
*/
protected function setHeadersAndStream(string $responseMode)
{
if (!$this->disk->exists($this->filePath)) {
report(new Exception('S3 File Not Found in S3FileStream - ' . $this->adapterName . ' - ' . $this->disk->path($this->filePath)));
return response('File Not Found', 404);
}
$this->start = 0;
$this->size = $this->disk->size($this->filePath);
$this->end = $this->size - 1;
$this->length = $this->size;
$this->isRange = false;
$downloadFileName = Str::finish($this->humanName ?? basename($this->filePath), '.' . Arr::last(explode('.', $this->filePath)));
$downloadFileName = static::sanitizeFilename($downloadFileName);
//Set headers
$this->returnHeaders = [
'Last-Modified' => $this->disk->lastModified($this->filePath),
'Accept-Ranges' => 'bytes',
'Content-Type' => $this->disk->mimeType($this->filePath),
'Content-Disposition' => $responseMode . '; filename=' . $downloadFileName,
'Content-Length' => $this->length,
];
//Handle ranges here
if (!is_null(request()->server('HTTP_RANGE'))) {
$cStart = $this->start;
$cEnd = $this->end;
$range = Str::after(request()->server('HTTP_RANGE'), '=');
if (strpos($range, ',') !== false) {
return response('416 Requested Range Not Satisfiable', 416, [
'Content-Range' => 'bytes */' . $this->size,
]);
}
if (substr($range, 0, 1) == '-') {
$cStart = $this->size - intval(substr($range, 1)) - 1;
} else {
$range = explode('-', $range);
$cStart = intval($range[0]);
$cEnd = (isset($range[1]) && is_numeric($range[1])) ? intval($range[1]) : $cEnd;
}
$cEnd = min($cEnd, $this->size - 1);
if ($cStart > $cEnd || $cStart > $this->size - 1) {
return response('416 Requested Range Not Satisfiable', 416, [
'Content-Range' => 'bytes */' . $this->size,
]);
}
$this->start = intval($cStart);
$this->end = intval($cEnd);
$this->length = min($this->end - $this->start + 1, $this->size);
$this->returnHeaders['Content-Length'] = $this->length;
$this->returnHeaders['Content-Range'] = 'bytes ' . $this->start . '-' . $this->end . '/' . $this->size;
$this->isRange = true;
}
return $this->stream();
}
/**
* Stream file to client.
* @throws \Exception
* @return StreamedResponse
*/
protected function stream(): StreamedResponse
{
$this->adapter->getClient()->registerStreamWrapper();
// Create a stream context to allow seeking
$context = stream_context_create([
's3' => [
'seekable' => true,
],
]);
// Open a stream in read-only mode
if (!($stream = fopen("s3://{$this->adapter->getBucket()}/{$this->filePath}", 'rb', false, $context))) {
throw new Exception('Could not open stream for reading export [' . $this->filePath . ']');
}
if (isset($this->start) && $this->start > 0) {
fseek($stream, $this->start, SEEK_SET);
}
$remainingBytes = $this->length ?? $this->size;
$chunkSize = 100;
$video = response()->stream(
function () use ($stream, $remainingBytes, $chunkSize) {
while (!feof($stream) && $remainingBytes > 0) {
$toGrab = min($chunkSize, $remainingBytes);
echo fread($stream, $toGrab);
$remainingBytes -= $toGrab;
flush();
}
fclose($stream);
},
($this->isRange ? 206 : 200),
$this->returnHeaders
);
return $video;
}
}