175
common/Files/Response/DownloadFilesResponse.php
Executable file
175
common/Files/Response/DownloadFilesResponse.php
Executable file
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Support\Collection;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use ZipStream\ZipStream;
|
||||
|
||||
class DownloadFilesResponse
|
||||
{
|
||||
// basename with extension => count
|
||||
// for incrementing file names in zip for files that have duplicate name
|
||||
protected array $filesInZip = [];
|
||||
protected int $totalSize = 0;
|
||||
|
||||
public function __construct(
|
||||
protected FileResponseFactory $fileResponseFactory,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection|FileEntry[] $entries
|
||||
* @return mixed
|
||||
*/
|
||||
public function create($entries)
|
||||
{
|
||||
if ($entries->count() === 1 && $entries->first()->type !== 'folder') {
|
||||
return $this->fileResponseFactory->create(
|
||||
$entries->first(),
|
||||
'attachment',
|
||||
);
|
||||
} else {
|
||||
return $this->streamZip($entries);
|
||||
}
|
||||
}
|
||||
|
||||
private function streamZip(Collection $entries): StreamedResponse
|
||||
{
|
||||
return new StreamedResponse(
|
||||
function () use ($entries) {
|
||||
$timestamp = Carbon::now()->getTimestamp();
|
||||
$zip = new ZipStream(
|
||||
// downloading multiple files from s3 will error out without this
|
||||
defaultEnableZeroHeader: true,
|
||||
contentType: 'application/octet-stream',
|
||||
sendHttpHeaders: true,
|
||||
outputName: "download-$timestamp.zip",
|
||||
);
|
||||
|
||||
$this->fillZip($zip, $entries);
|
||||
$zip->finish();
|
||||
},
|
||||
200,
|
||||
[
|
||||
'X-Accel-Buffering' => 'no',
|
||||
'Pragma' => 'public',
|
||||
'Cache-Control' => 'no-cache',
|
||||
'Content-Transfer-Encoding' => 'binary',
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function fillZip(ZipStream $zip, Collection $entries): void
|
||||
{
|
||||
$entries->each(function (FileEntry $entry) use ($zip) {
|
||||
if ($entry->type === 'folder') {
|
||||
// this will load all children, nested at any level, so no need to do a recursive loop
|
||||
$entry
|
||||
->allChildren()
|
||||
->select([
|
||||
'id',
|
||||
'name',
|
||||
'extension',
|
||||
'path',
|
||||
'type',
|
||||
'file_name',
|
||||
'disk_prefix',
|
||||
])
|
||||
->orderBy('path', 'asc')
|
||||
->chunk(300, function (Collection $chunk) use (
|
||||
$zip,
|
||||
$entry,
|
||||
) {
|
||||
$chunk->each(function (FileEntry $childEntry) use (
|
||||
$zip,
|
||||
$entry,
|
||||
$chunk,
|
||||
) {
|
||||
$path = $this->transformPath(
|
||||
$childEntry,
|
||||
$entry,
|
||||
$chunk,
|
||||
);
|
||||
if ($childEntry->type === 'folder') {
|
||||
// add empty folder in case it has no children
|
||||
$zip->addFile("$path/", '');
|
||||
} else {
|
||||
$this->addFileToZip($childEntry, $zip, $path);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
$this->addFileToZip($entry, $zip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function addFileToZip(
|
||||
FileEntry $entry,
|
||||
ZipStream $zip,
|
||||
string|null $path = null,
|
||||
): void {
|
||||
if (!$path) {
|
||||
$path = $entry->getNameWithExtension();
|
||||
}
|
||||
|
||||
$parts = pathinfo($path);
|
||||
$basename = $parts['basename'];
|
||||
$filename = $parts['filename'];
|
||||
$extension = $parts['extension'];
|
||||
$dirname = $parts['dirname'] === '.' ? '' : $parts['dirname'];
|
||||
|
||||
// add number to duplicate file names (file(1).png, file(2).png etc)
|
||||
if (isset($this->filesInZip[$basename])) {
|
||||
$newCount = $this->filesInZip[$basename] + 1;
|
||||
$this->filesInZip[$basename] = $newCount;
|
||||
$path = "$dirname/$filename($newCount).$extension";
|
||||
} else {
|
||||
$this->filesInZip[$basename] = 0;
|
||||
}
|
||||
|
||||
$stream = $entry->getDisk()->readStream($entry->getStoragePath());
|
||||
if ($stream) {
|
||||
$zip->addFileFromStream($path, $stream);
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace entry IDs with names inside "path" property.
|
||||
*/
|
||||
private function transformPath(
|
||||
FileEntry $entry,
|
||||
FileEntry $parent,
|
||||
Collection $folders,
|
||||
): string {
|
||||
if (!$entry->path) {
|
||||
return $entry->getNameWithExtension();
|
||||
}
|
||||
|
||||
// '56/55/54 => [56,55,54]
|
||||
$path = array_filter(explode('/', $entry->path));
|
||||
$path = array_map(function ($id) {
|
||||
return (int) $id;
|
||||
}, $path);
|
||||
|
||||
//only generate path until specified parent and not root
|
||||
$path = array_slice($path, array_search($parent->id, $path));
|
||||
|
||||
// last value will be id of the file itself, remove it
|
||||
array_pop($path);
|
||||
|
||||
// map parent folder IDs to names
|
||||
$path = array_map(function ($id) use ($folders, $parent) {
|
||||
if ($id === $parent->id) {
|
||||
return $parent->name;
|
||||
}
|
||||
return $folders->find($id)->name;
|
||||
}, $path);
|
||||
|
||||
return implode('/', $path) . '/' . $entry->getNameWithExtension();
|
||||
}
|
||||
}
|
||||
15
common/Files/Response/FileResponse.php
Executable file
15
common/Files/Response/FileResponse.php
Executable file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
interface FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options);
|
||||
}
|
||||
74
common/Files/Response/FileResponseFactory.php
Executable file
74
common/Files/Response/FileResponseFactory.php
Executable file
@@ -0,0 +1,74 @@
|
||||
<?php namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use League\Flysystem\Local\LocalFilesystemAdapter;
|
||||
use Request;
|
||||
|
||||
class FileResponseFactory
|
||||
{
|
||||
public function create(
|
||||
FileEntry $entry,
|
||||
string $disposition = 'inline',
|
||||
): mixed {
|
||||
$options = [
|
||||
'useThumbnail' => Request::get('thumbnail') && $entry->thumbnail,
|
||||
'disposition' => $disposition,
|
||||
];
|
||||
|
||||
return $this->resolveResponseClass($entry, $disposition)->make(
|
||||
$entry,
|
||||
$options,
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveResponseClass(
|
||||
FileEntry $entry,
|
||||
string $disposition = 'inline',
|
||||
): FileResponse {
|
||||
$isLocalDrive =
|
||||
$entry->getDisk()->getAdapter() instanceof LocalFilesystemAdapter;
|
||||
$staticFileDelivery = config('common.site.static_file_delivery');
|
||||
|
||||
if ($this->shouldRedirectToRemoteUrl($entry)) {
|
||||
return new RemoteFileResponse();
|
||||
} elseif ($isLocalDrive && !$entry->public && $staticFileDelivery) {
|
||||
return $staticFileDelivery === 'xsendfile'
|
||||
? new XSendFileResponse()
|
||||
: new XAccelRedirectFileResponse();
|
||||
} elseif (
|
||||
!$isLocalDrive &&
|
||||
config('common.site.use_presigned_s3_urls')
|
||||
) {
|
||||
return new StreamedFileResponse();
|
||||
} elseif (
|
||||
$disposition === 'inline' &&
|
||||
$this->shouldReturnRangeResponse($entry)
|
||||
) {
|
||||
return new RangeFileResponse();
|
||||
} else {
|
||||
return new StreamedFileResponse();
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldReturnRangeResponse(FileEntry $entry): bool
|
||||
{
|
||||
return $entry->type === 'video' ||
|
||||
$entry->type === 'audio' ||
|
||||
$entry->mime === 'application/ogg';
|
||||
}
|
||||
|
||||
private function shouldRedirectToRemoteUrl(FileEntry $entry): bool
|
||||
{
|
||||
$adapter = $entry->getDisk()->getAdapter();
|
||||
$isS3 = $adapter instanceof AwsS3V3Adapter;
|
||||
$shouldUsePublicUrl =
|
||||
config('common.site.remote_file_visibility') === 'public' && $isS3;
|
||||
$shouldUsePresignedUrl =
|
||||
config('common.site.use_presigned_s3_urls') && $isS3;
|
||||
$hasCustomCdnUrl = config('common.site.file_preview_endpoint');
|
||||
return $shouldUsePresignedUrl ||
|
||||
$shouldUsePublicUrl ||
|
||||
$hasCustomCdnUrl;
|
||||
}
|
||||
}
|
||||
66
common/Files/Response/RangeFileResponse.php
Executable file
66
common/Files/Response/RangeFileResponse.php
Executable file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class RangeFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$disk = $entry->getDisk();
|
||||
$size = $disk->size($entry->getStoragePath());
|
||||
$time = date('r', $entry->updated_at->timestamp);
|
||||
$fm = $disk->getDriver()->readStream($entry->getStoragePath());
|
||||
$begin = 0;
|
||||
$end = $size - 1;
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
if (preg_match('/bytes=\h*(\d+)-(\d*)[\D.*]?/i', $_SERVER['HTTP_RANGE'], $matches))
|
||||
{
|
||||
$begin = intval($matches[1]);
|
||||
if (!empty($matches[2]))
|
||||
{
|
||||
$end = intval($matches[2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
header('HTTP/1.1 206 Partial Content');
|
||||
}
|
||||
else
|
||||
{
|
||||
header('HTTP/1.1 200 OK');
|
||||
}
|
||||
|
||||
header("Content-Type: $entry->mime");
|
||||
header('Cache-Control: public, must-revalidate, max-age=0');
|
||||
header('Pragma: no-cache');
|
||||
header('Accept-Ranges: bytes');
|
||||
header('Content-Length:' . (($end - $begin) + 1));
|
||||
if (isset($_SERVER['HTTP_RANGE']))
|
||||
{
|
||||
header("Content-Range: bytes $begin-$end/$size");
|
||||
}
|
||||
header("Content-Disposition: inline; filename={$entry->getNameWithExtension()}");
|
||||
header("Content-Transfer-Encoding: binary");
|
||||
header("Last-Modified: $time");
|
||||
|
||||
$cur = $begin;
|
||||
fseek($fm, $begin, 0);
|
||||
|
||||
while(!feof($fm) && $cur <= $end && (connection_status() == 0))
|
||||
{
|
||||
print fread($fm, min(1024 * 16, ($end - $cur) + 1));
|
||||
$cur += 1024 * 16;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
common/Files/Response/RemoteFileResponse.php
Executable file
53
common/Files/Response/RemoteFileResponse.php
Executable file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class RemoteFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options): mixed
|
||||
{
|
||||
if ($options['disposition'] === 'attachment') {
|
||||
$fileName = rawurlencode($entry->name);
|
||||
return $this->getTemporaryUrl($entry, $options, [
|
||||
'ResponseContentType' => 'application/octet-stream',
|
||||
'ResponseContentDisposition' => "attachment;filename={$fileName}",
|
||||
]);
|
||||
} else {
|
||||
if (config('common.site.use_presigned_s3_urls')) {
|
||||
return $this->getTemporaryUrl($entry, $options, [
|
||||
'ResponseContentType' => $entry->mime,
|
||||
]);
|
||||
} else {
|
||||
return redirect(
|
||||
$entry
|
||||
->getDisk()
|
||||
->url($entry->getStoragePath($options['useThumbnail'])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function getTemporaryUrl(
|
||||
FileEntry $entry,
|
||||
array $entryOptions,
|
||||
array $urlOptions,
|
||||
) {
|
||||
return redirect(
|
||||
$entry
|
||||
->getDisk()
|
||||
->temporaryUrl(
|
||||
$entry->getStoragePath($entryOptions['useThumbnail']),
|
||||
Carbon::now()->addMinutes(30),
|
||||
$urlOptions,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
56
common/Files/Response/StreamedFileResponse.php
Executable file
56
common/Files/Response/StreamedFileResponse.php
Executable file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
use Illuminate\Support\Str;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class StreamedFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$downloadName = str_replace(
|
||||
['%', '/'],
|
||||
'',
|
||||
$entry->getNameWithExtension(),
|
||||
);
|
||||
|
||||
$path = $entry->getStoragePath($options['useThumbnail']);
|
||||
$response = new StreamedResponse();
|
||||
$disposition = $response->headers->makeDisposition(
|
||||
$options['disposition'],
|
||||
$downloadName,
|
||||
Str::ascii($downloadName),
|
||||
);
|
||||
|
||||
$fileSize = $options['useThumbnail']
|
||||
? $entry->getDisk()->size($path)
|
||||
: $entry->file_size;
|
||||
|
||||
$response->headers->replace([
|
||||
'Content-Type' => $entry->mime,
|
||||
'Content-Length' => $fileSize,
|
||||
'Content-Disposition' => $disposition,
|
||||
'Cache-Control' => 'private, max-age=31536000, no-transform',
|
||||
//'X-Accel-Buffering' => 'no',
|
||||
]);
|
||||
$response->setCallback(function () use ($entry, $path) {
|
||||
$stream = $entry->getDisk()->readStream($path);
|
||||
if (!$stream) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
while (!feof($stream)) {
|
||||
echo fread($stream, 2048);
|
||||
}
|
||||
fclose($stream);
|
||||
});
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
23
common/Files/Response/XAccelRedirectFileResponse.php
Executable file
23
common/Files/Response/XAccelRedirectFileResponse.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class XAccelRedirectFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$disposition = $options['disposition'];
|
||||
header('X-Media-Root: ' . storage_path('app/uploads'));
|
||||
header("X-Accel-Redirect: /uploads/{$entry->getStoragePath($options['useThumbnail'])}");
|
||||
header("Content-Type: {$entry->mime}");
|
||||
header("Content-Disposition: $disposition; filename=\"".$entry->getNameWithExtension().'"');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
23
common/Files/Response/XSendFileResponse.php
Executable file
23
common/Files/Response/XSendFileResponse.php
Executable file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Common\Files\Response;
|
||||
|
||||
use Common\Files\FileEntry;
|
||||
|
||||
class XSendFileResponse implements FileResponse
|
||||
{
|
||||
/**
|
||||
* @param FileEntry $entry
|
||||
* @param array $options
|
||||
* @return mixed
|
||||
*/
|
||||
public function make(FileEntry $entry, $options)
|
||||
{
|
||||
$path = storage_path('app/uploads').'/'.$entry->getStoragePath($options['useThumbnail']);
|
||||
$disposition = $options['disposition'];
|
||||
header("X-Sendfile: $path");
|
||||
header("Content-Type: {$entry->mime}");
|
||||
header("Content-Disposition: $disposition; filename=\"".$entry->getNameWithExtension().'"');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user