first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View 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();
}
}

View 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);
}

View 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;
}
}

View 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;
}
}
}

View 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,
),
);
}
}

View 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;
}
}

View 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;
}
}

View 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;
}
}