Changeset View
Changeset View
Standalone View
Standalone View
src/app/Backends/Storage.php
- This file was added.
<?php | |||||
namespace App\Backends; | |||||
use App\File; | |||||
use App\Library; | |||||
use Illuminate\Http\Request; | |||||
use Illuminate\Support\Facades\Cache; | |||||
use Illuminate\Support\Facades\Storage as LaravelStorage; | |||||
use Illuminate\Support\Str; | |||||
use Symfony\Component\HttpFoundation\StreamedResponse; | |||||
class Storage | |||||
{ | |||||
/** @const How long the resumable upload "token" is valid (in seconds) */ | |||||
public const UPLOAD_TTL = 60 * 60 * 6; | |||||
/** | |||||
* Delete a file. | |||||
* | |||||
* @param \App\File $file File object | |||||
* | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileDelete(File $file): void | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
$path = $file->path . '/' . $file->id; | |||||
$disk->delete($path); | |||||
} | |||||
/** | |||||
* File download handler. | |||||
* | |||||
* @param \App\File $file File object | |||||
* | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileDownload(File $file): StreamedResponse | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
$path = $file->path . '/' . $file->id; | |||||
// I noticed that the Laravel's download() method can fail with an exception | |||||
// thrown by mimetype detector. To prevent that we do this with a custom code | |||||
// return LaravelStorage::download($path, $file->name, $headers); | |||||
$response = new StreamedResponse(); | |||||
// Prepare the file name for the Content-Disposition header | |||||
$extension = pathinfo($file->name, \PATHINFO_EXTENSION) ?: 'file'; | |||||
$fallbackName = str_replace('%', '', Str::ascii($file->name)) ?: "file.{$extension}"; | |||||
$disposition = $response->headers->makeDisposition('attachment', $file->name, $fallbackName); | |||||
$response->headers->replace([ | |||||
'Content-Length' => $file->size, | |||||
'Content-Type' => $file->mimetype, | |||||
'Content-Disposition' => $disposition, | |||||
]); | |||||
$response->setCallback(function () use ($disk, $path) { | |||||
$stream = $disk->readStream($path); | |||||
fpassthru($stream); | |||||
fclose($stream); | |||||
}); | |||||
return $response; | |||||
} | |||||
/** | |||||
* File upload handler | |||||
* | |||||
* @param \App\Library $library The library to which the file belongs | |||||
* @param resource $stream File input stream | |||||
* @param array $params Request parameters | |||||
* | |||||
* @return array File/Response attributes | |||||
* @throws \Exception | |||||
*/ | |||||
public static function fileInput(Library $library, $stream, array $params): array | |||||
{ | |||||
if (!empty($params['uploadId'])) { | |||||
return self::fileInputResumable($library, $stream, $params); | |||||
} | |||||
$disk = LaravelStorage::disk('files'); | |||||
$file = $library->files()->create([ | |||||
'mimetype' => File::TYPE_INCOMPLETE, | |||||
'name' => $params['name'], | |||||
]); | |||||
$path = $file->path . '/' . $file->id; | |||||
$disk->writeStream($path, $stream); | |||||
// Update the file type and size information | |||||
$file->size = $disk->fileSize($path); | |||||
$file->mimetype = self::mimetype($path); | |||||
$file->save(); | |||||
return $file->toArray(); | |||||
} | |||||
/** | |||||
* Resumable file upload handler | |||||
* | |||||
* @param \App\Library $library The library to which the file belongs | |||||
* @param resource $stream File input stream | |||||
* @param array $params Request parameters | |||||
* | |||||
* @return array File/Response attributes | |||||
* @throws \Exception | |||||
*/ | |||||
protected static function fileInputResumable(Library $library, $stream, array $params): array | |||||
{ | |||||
$init = $params['uploadId'] == 'resumable'; | |||||
if ($init) { | |||||
if (empty($params['size'])) { | |||||
// error | |||||
} | |||||
$file = $library->files()->create([ | |||||
'mimetype' => File::TYPE_INCOMPLETE, | |||||
'name' => $params['name'], | |||||
]); | |||||
$params['uploadId'] = \App\Utils::uuidStr(); | |||||
$upload = [ | |||||
'fileId' => $file->id, | |||||
'size' => $params['size'], | |||||
'uploaded' => 0, | |||||
'libraryId' => $library->id, | |||||
]; | |||||
if (!Cache::add('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL)) { | |||||
// error | |||||
} | |||||
// Empty initial request? return early | |||||
$byte = fread($stream, 1); | |||||
if ($byte === false || $byte === '') { | |||||
return ['uploadId' => $params['uploadId'], 'uploaded' => 0]; | |||||
} | |||||
rewind($stream); | |||||
} else { | |||||
$upload = Cache::get('upload:' . $params['uploadId']); | |||||
if (empty($upload)) { | |||||
// error | |||||
} | |||||
$file = File::find($upload['fileId']); | |||||
if (!$file) { | |||||
// error | |||||
} | |||||
} | |||||
$from = $params['from'] ?? 0; | |||||
// Sanity checks on the input parameters | |||||
// TODO: Support uploading again a chunk that already has been uploaded | |||||
if ($from < $upload['uploaded'] || $from > $upload['uploaded'] || $from > $upload['size']) { | |||||
// error | |||||
} elseif ($library->id != $upload['libraryId']) { | |||||
// error | |||||
} | |||||
$disk = LaravelStorage::disk('files'); | |||||
$path = 'tmp/' . $params['uploadId'] . '/' . sprintf('%010d', $from); | |||||
// Save the file chunk | |||||
$disk->writeStream($path, $stream); | |||||
$fsize = $disk->fileSize($path); | |||||
// Update the file type and size information after the upload of the whole | |||||
// file is completed | |||||
if ($fsize + $upload['uploaded'] >= $upload['size']) { | |||||
// Put uploaded chunks together | |||||
$files = []; | |||||
foreach ($disk->listContents('tmp/' . $params['uploadId']) as $fileAttr) { | |||||
$files[] = $fileAttr->path(); | |||||
} | |||||
$target = $file->path . '/' . $file->id; | |||||
if (count($files) == 1) { | |||||
$disk->move($files[0], $target); | |||||
} else { | |||||
sort($files); | |||||
self::concatenate($target, $files); | |||||
} | |||||
// Update file metadata | |||||
$file->size = $disk->fileSize($target); | |||||
$file->mimetype = self::mimetype($target); | |||||
$file->save(); | |||||
// Delete the upload cache record | |||||
Cache::forget('upload:' . $params['uploadId']); | |||||
// TODO: There should be a job that removes orphaned files/folders from the temp folder anyway | |||||
// So we could ignore that here to make the response faster | |||||
$disk->deleteDirectory('tmp/' . $params['uploadId']); | |||||
return $file->toArray(); | |||||
} | |||||
$upload['uploaded'] += $fsize; | |||||
Cache::put('upload:' . $params['uploadId'], $upload, self::UPLOAD_TTL); | |||||
return ['uploadId' => $params['uploadId'], 'uploaded' => $upload['uploaded']]; | |||||
} | |||||
/** | |||||
* Concatenate multiple files into a target location | |||||
* | |||||
* @param string $target Target file location | |||||
* @param array $files Source files | |||||
* | |||||
* @throws \Exception | |||||
*/ | |||||
protected static function concatenate(string $target, array $files): void | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
// If the target file exists, rename it to <name>.backup | |||||
if ($disk->fileExists($target)) { | |||||
$disk->move($target, $backup = "{$target}.backup"); | |||||
} | |||||
try { | |||||
$streams = []; | |||||
foreach ($files as $file) { | |||||
$streams[] = $disk->readStream($file); | |||||
} | |||||
$stream = self::getResource($streams); | |||||
$disk->writeStream($target, $stream); | |||||
fclose($stream); | |||||
foreach ($streams as $stream) { | |||||
@fclose($stream); | |||||
} | |||||
if (!empty($backup)) { | |||||
$disk->delete($backup); | |||||
} | |||||
} catch (\Exception $e) { | |||||
// On error rename the backup file back to the original | |||||
if (!empty($backup)) { | |||||
$disk->move($backup, $target); | |||||
} | |||||
throw $e; | |||||
} | |||||
} | |||||
/** | |||||
* Get the file mime type. | |||||
* | |||||
* @param string $path File location | |||||
* | |||||
* @return string File mime type | |||||
*/ | |||||
protected static function mimetype(string $path): string | |||||
{ | |||||
$disk = LaravelStorage::disk('files'); | |||||
// TODO: If file is empty, detect the mimetype based on the extension? | |||||
try { | |||||
return $disk->mimeType($path); | |||||
} catch (\Exception $e) { | |||||
// do nothing | |||||
} | |||||
// TODO: If it fails detect the mimetype based on extension? | |||||
return 'application/octet-stream'; | |||||
} | |||||
/** | |||||
* Create a stream containing concatenated content of multiple streams | |||||
* | |||||
* @param array<resource> $streams Resource streams | |||||
* @param int $chunkSize Chunk size for copying operation | |||||
* | |||||
* @return resource | |||||
*/ | |||||
protected static function getResource($streams, int $chunkSize = 8192) | |||||
{ | |||||
if (empty($streams)) { | |||||
return fopen('data://text/plain,','r'); | |||||
} | |||||
if (count($streams) == 1) { | |||||
return reset($streams); | |||||
} | |||||
// Write all input streams data into one | |||||
// Note: The temp file will be created only if there's not enough memory. | |||||
// We can use php://temp/memory:XX if we wanted to set the limit here | |||||
$output = fopen('php://temp','r+'); | |||||
foreach ($streams as $stream) { | |||||
stream_copy_to_stream($stream, $output); | |||||
} | |||||
rewind($output); | |||||
// TODO: Find why the filter-based approach below does not work | |||||
// TODO: Find another approach. Probably depending on the storage driver | |||||
// there will be better API to do the append operation faster. | |||||
// For example in filesystem we could just execute cat command. | |||||
// AWS/S3-type APIs might have also ways to do this better. | |||||
return $output; | |||||
/* | |||||
// Note: The code below copied from keven/append-stream is working, but | |||||
// has the memory limit issue, even though it's supposed to have not | |||||
$head = tmpfile(); | |||||
fwrite($head, fread($streams[0], 8192)); | |||||
rewind($head); | |||||
$anonymous = new class($streams, $chunkSize) extends \php_user_filter | |||||
{ | |||||
private static $streams = []; | |||||
private static $maxLength; | |||||
public function __construct(array $streams = [], int $maxLength = 8192) | |||||
{ | |||||
self::$streams = $streams; | |||||
self::$maxLength = $maxLength; | |||||
} | |||||
public function filter($in, $out, &$consumed, $closing): int | |||||
{ | |||||
while ($bucket = stream_bucket_make_writeable($in)) { | |||||
stream_bucket_append($out, $bucket); | |||||
} | |||||
foreach (self::$streams as $idx => $stream) { | |||||
while (feof($stream) !== true) { | |||||
$bucket = stream_bucket_new($stream, fread($stream, self::$maxLength)); | |||||
stream_bucket_append($out, $bucket); | |||||
} | |||||
} | |||||
return PSFS_PASS_ON; | |||||
} | |||||
}; | |||||
$filter = bin2hex(random_bytes(32)); | |||||
stream_filter_register($filter, get_class($anonymous)); | |||||
stream_filter_append($head, $filter); | |||||
return $head; | |||||
*/ | |||||
} | |||||
} |